Commit: 351ade3
Parent: 33983d7

Fix scrolling

Mårten Åsberg committed on 2026-05-16 at 14:51
AGENTS.md +13 -18
diff --git a/AGENTS.md b/AGENTS.md
index f851fc7..3cc339c 100644
@@ -40,11 +40,11 @@ pnpm -C src/Frontend dev
dotnet run --project src/Cli/Cli.csproj
```
For full E2E testing, build the frontend into the API's static files directory:
For manual E2E testing, run the Api project in the background:
```sh
pnpm -C src/Frontend build --emptyOutDir --outDir ../Api/wwwroot/
dotnet run --project src/Api/Api.csproj
dotnet run --project src/Api/Api.csproj --launch-profile proxied &
pnpm dev
```
## Database Migrations
@@ -122,20 +122,15 @@ Migrations run automatically on startup of `Api` and `Cli`.
### Testing
- Manual E2E tests use Playwright CLI (`@playwright/cli`).
- To run E2E tests the frontend must be built into `src/Api/wwwroot/` and the Api must be running on port 5055.
- After **any** frontend change, do a manual E2E test with Playwright CLI (`@playwright/cli`).
- Run from the project root:
```sh
pnpm -C src/Frontend build --emptyOutDir --outDir ../Api/wwwroot/
dotnet run --project src/Api/Api.csproj & # start in background
pnpm playwright-cli "<natural language instruction, e.g. navigate to http://localhost:5055 and take a screenshot>"
```
Consult the user with the following command
```sh
pnpm playwright-cli show --annotate
```
```sh
dotnet run --project src/Api/Api.csproj --launch-profile proxied & # start in background
pnpm -C src/Frontend dev & # also in background
pnpm playwright-cli "<natural language instruction, e.g. navigate to http://localhost:5055 and take a screenshot>"
```
- Consult the user for UI/UX feedback with the following command
```sh
pnpm playwright-cli show --annotate
```
- Chromium (Chrome) may not be installed; in such cases try command again with `--browser msedge`.
README.md +8 -7
diff --git a/README.md b/README.md
index a057de2..5f883cc 100644
@@ -67,24 +67,25 @@ pnpm dev # Vite dev server with hot reload
pnpm build # Production build (vue-tsc + vite)
```
`pnpm dev` will output a "random" port once it starts.
`pnpm dev` starts the frontend on port 5055.
### API (`src/Api/`)
Start the proxies `Api` in the background to test the frontend fully:
```sh
dotnet run --project src/Api/Api.csproj
dotnet run --project src/Api/Api.csproj --launch-profile proxied &
pnpm dev
```
The Api project runs on port 5055 by default.
This is great for manual E2E tests.
To perform end-to-end tests with both the `Frontend` and the `Api`, build the frontend and move the output from `src/Frontend/dist` to `src/Api/wwwroot`, then run the `Api` project. The `Api` project will serve the frontend from the same path.
### API (`src/Api/`)
For example, from project root:
```sh
pnpm -C src/Frontend build --emptyOutDir --outDir ../Api/wwwroot/
dotnet run --project src/Api/Api.csproj
```
The Api project runs on port 5055 by default.
### CLI (`src/Cli/`)
```sh
src/Api/CleanupJob.cs +9 -4
diff --git a/src/Api/CleanupJob.cs b/src/Api/CleanupJob.cs
index 170ea77..231653f 100644
@@ -29,9 +29,14 @@ public static class CleanupJobExtensions
{
extension(IServiceCollectionQuartzConfigurator quartz)
{
public IServiceCollectionQuartzConfigurator AddCleanupJob() =>
quartz
.AddJob<CleanupJob>(CleanupJob.Key, options => options.StoreDurably())
.AddTrigger(options => options.ForJob(CleanupJob.Key).WithCronSchedule("0 0 * * * ?"));
public IServiceCollectionQuartzConfigurator AddCleanupJob(bool runOnSchedule)
{
quartz.AddJob<CleanupJob>(CleanupJob.Key, options => options.StoreDurably());
if (runOnSchedule)
{
quartz.AddTrigger(options => options.ForJob(CleanupJob.Key).WithCronSchedule("0 0 * * * ?"));
}
return quartz;
}
}
}
src/Api/Program.cs +4 -1
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index e6dfb5d..8eaa94e 100644
@@ -40,7 +40,10 @@ builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().
builder
.Services.AddClipGenerationJobOptions()
.AddQuartz(quartz =>
quartz.AddClipGenerationJob(startNow: !builder.Environment.IsDevelopment()).AddCleanupJob().AddUploadJob()
quartz
.AddClipGenerationJob(startNow: !builder.Environment.IsDevelopment())
.AddCleanupJob(runOnSchedule: !builder.Environment.IsDevelopment())
.AddUploadJob()
)
.AddQuartzServer();
src/Api/Properties/launchSettings.json +9 -0
diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json
index 91f0d3a..90e03e3 100644
@@ -9,6 +9,15 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"proxied": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5056",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
src/Cli/Program.cs +27 -3
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index 2f8449e..2ae4b59 100644
@@ -1,9 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Slopper.Cli;
using Slopper.Cli.YouTubeAuth;
using Slopper.Domain;
using Slopper.Domain.Describer;
using Slopper.Infrastructure.Ai;
using Slopper.Infrastructure.Database;
using Slopper.Infrastructure.Ffmpeg;
@@ -27,8 +30,29 @@ var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
using var scope = app.Services.CreateScope();
var cleaner = scope.ServiceProvider.GetRequiredService<Cleaner>();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
var clipSelector = scope.ServiceProvider.GetRequiredService<ClipSelector>();
var clipRepository = scope.ServiceProvider.GetRequiredService<IClipRepository>();
var clipDescriber = scope.ServiceProvider.GetRequiredService<ClipDescriber>();
var clipExtractor = scope.ServiceProvider.GetRequiredService<IClipExtractor>();
await cleaner.Cleanup(lifetime.ApplicationStopping);
var media = new MediaItem(Guid.Parse(args[0]), args[1], new Subtitles.Embedded(int.Parse(args[2])));
var (start, duration) = await clipSelector.PickClip(media, lifetime.ApplicationStopping);
var utcNow = timeProvider.GetUtcNow();
var clipId = Guid.CreateVersion7(utcNow);
var clipPath = Path.Join(args[3], $"{clipId}.mp4");
var descriptionTask = clipDescriber.DescribeClip(media, start, duration, lifetime.ApplicationStopping);
await clipExtractor.ExtractClip(media, start, duration, clipPath, lifetime.ApplicationStopping);
var description = await descriptionTask;
var clip = new Clip(clipId, media.Id, clipPath, start, duration, utcNow, description.Caption)
{
Tags = description.Tags,
};
await clipRepository.Save(clip, lifetime.ApplicationStopping);
await app.StopAsync();
src/Frontend/src/components/ClipItem.vue +41 -4
diff --git a/src/Frontend/src/components/ClipItem.vue b/src/Frontend/src/components/ClipItem.vue
index f93beee..caa1551 100644
@@ -2,14 +2,49 @@
import type { Clip } from "../Clip";
import ClipVideo from "./ClipVideo.vue";
import ClipDetails from "./ClipDetails.vue";
import { ref } from "vue";
const { clip } = defineProps<{ clip: Clip }>();
const emit = defineEmits<{ visible: [] }>();
const clipEl = ref<HTMLDivElement | null>(null);
const rem = parseFloat(window.getComputedStyle(document.body).fontSize);
function onSwipeHint() {
if (clipEl.value == null) {
return;
}
clipEl.value.style.scrollSnapType = "unset";
clipEl.value.addEventListener(
"scrollend",
() => {
if (clipEl.value == null) {
return;
}
clipEl.value.addEventListener(
"scrollend",
() => {
if (clipEl.value == null) {
return;
}
clipEl.value.style.scrollSnapType = "";
},
{ once: true, passive: true },
);
clipEl.value.scrollTo({ left: 0, behavior: "smooth" });
},
{ once: true, passive: true },
);
clipEl.value.scrollBy({ left: 5 * rem, behavior: "smooth" });
}
</script>
<template>
<div class="clip">
<ClipVideo :clip="clip" @visible="emit('visible')" />
<div class="clip" ref="clipEl">
<ClipVideo :clip="clip" @visible="emit('visible')" @swipe-hint-tap="onSwipeHint" />
<ClipDetails :clip="clip" />
</div>
</template>
@@ -18,10 +53,12 @@ const emit = defineEmits<{ visible: [] }>();
.clip {
display: flex;
height: 100dvh;
overflow-x: scroll;
scroll-snap-type: x mandatory;
overflow: scroll hidden;
scroll-snap-type: inline mandatory;
scroll-snap-align: start;
scroll-snap-stop: always;
scroll-timeline: --clip inline;
view-timeline: --feed block 100% 0%;
scrollbar-width: none;
}
src/Frontend/src/components/ClipVideo.vue +40 -9
diff --git a/src/Frontend/src/components/ClipVideo.vue b/src/Frontend/src/components/ClipVideo.vue
index 6d021b0..4c15be0 100644
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted } from "vue";
import type { Clip } from "../Clip";
const { clip } = defineProps<{ clip: Clip }>();
const emit = defineEmits<{ visible: [] }>();
const emit = defineEmits<{ visible: [], swipeHintTap: [] }>();
const videoEl = ref<HTMLVideoElement | null>(null);
@@ -44,23 +44,36 @@ onUnmounted(() => observer?.disconnect());
<span>#{{ tag }}</span>
</template>
</p>
<span class="swipe-hint" aria-hidden="true">
<div class="swipe-hint" aria-hidden="true" @click="emit('swipeHintTap')">
<svg viewBox="0 0 6 12" width="6" height="12" fill="none"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="1,1 5,6 1,11"/>
</svg>
</span>
</div>
</div>
</div>
</template>
<style scoped>
@property --shadow {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.video-panel {
flex: 0 0 100vw;
height: 100dvh;
scroll-snap-align: start;
position: relative;
animation: darken linear;
animation-timeline: --clip;
}
@keyframes darken {
0% { filter: brightness(1); }
100% { filter: brightness(0.25); }
}
video {
@@ -69,18 +82,36 @@ video {
object-fit: cover;
}
.video-panel::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 0rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 100%);
animation: shorten linear;
animation-timeline: --feed;
}
@keyframes shorten {
0% { height: 1.2rem }
75% { height: 1.2rem }
100% { height: 0rem }
}
.caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
padding-inline-start: 1rem;
padding-block: 1rem;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 1.2rem);
color: #ffffff;
line-height: 1.4;
display: flex;
align-items: flex-end;
gap: 0.75rem;
}
.caption p {
@@ -89,11 +120,11 @@ video {
}
.swipe-hint {
flex: 0 0 auto;
align-self: center;
color: rgba(255, 255, 255, 0.5);
align-self: stretch;
flex: 0 0 2rem;
display: flex;
align-items: center;
pointer-events: none;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
}
</style>
src/Frontend/vite.config.ts +6 -0
diff --git a/src/Frontend/vite.config.ts b/src/Frontend/vite.config.ts
index e3d9890..6148a60 100644
@@ -4,4 +4,10 @@ import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 5055,
proxy: {
"/api": "http://localhost:5056",
},
},
});