diff --git a/web/app/package.json b/web/app/package.json new file mode 100644 index 0000000..96db492 --- /dev/null +++ b/web/app/package.json @@ -0,0 +1,18 @@ +{ + "name": "flixy-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.5" + }, + "dependencies": { + "hls.js": "^1.5.18" + } +} diff --git a/web/app/tsconfig.json b/web/app/tsconfig.json new file mode 100644 index 0000000..c497773 --- /dev/null +++ b/web/app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/app/vite.config.ts b/web/app/vite.config.ts new file mode 100644 index 0000000..f2873dc --- /dev/null +++ b/web/app/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5173, + // Im Entwicklungsbetrieb übernimmt Vite, was sonst der eigene Server tut: + // Anfragen an /api gehen an den Medienserver weiter. Damit ist die + // Herkunft dieselbe wie die der Seite — und CORS nie wieder ein Thema. + proxy: { + '/api': { + target: process.env.FLIXY_SERVER ?? 'http://localhost:8096', + changeOrigin: true, + rewrite: (pfad) => pfad.replace(/^\/api/, ''), + }, + }, + }, + build: { outDir: 'dist', sourcemap: true }, +}); diff --git a/web/frontend/index.html b/web/frontend/index.html index a8c7384..21b0f97 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -1754,8 +1754,14 @@ function openPlayer(item) { // --- Schließen --- function close() { removeEventListener('keydown', keys); + document.removeEventListener('visibilitychange', wieder); clearTimeout(hideTimer); if (document.fullscreenElement) document.exitFullscreen?.(); + + // **Erst melden, dann abräumen.** Andersherum steht die Stelle schon auf + // null, wenn die Meldung hinausgeht. + lauf.sitzung?.melden?.('/Sessions/Playing/Stopped'); + v.__hls?.destroy(); v.__hls = null; v.pause(); v.removeAttribute('src'); v.load(); layer.remove(); @@ -1763,6 +1769,23 @@ function openPlayer(item) { // Die Startseite kennt den neuen Fortschritt noch nicht. if (current === 'home') renderHome(); } + + /* **Zurück aus dem Hintergrund.** + Wer die Anwendung wechselt, kommt zu einem stehenden Bild bei laufendem + Ton zurück: der Browser hält das Entpacken an, während der Tab verdeckt + ist, und hls.js findet danach nicht von selbst zurück in den Puffer. + Ein Anstoß an derselben Stelle setzt das Bild wieder in Gang. */ + const wieder = () => { + if (document.hidden || v.paused) return; + const h = v.__hls; + if (!h) return; + const stelle = v.currentTime; + h.startLoad(stelle); + // Ein winziger Sprung erzwingt einen neuen Schlüsselbildpunkt. Ohne ihn + // bleibt das zuletzt gezeigte Bild stehen, obwohl Segmente ankommen. + if (stelle > 0.5) v.currentTime = stelle - 0.1; + }; + document.addEventListener('visibilitychange', wieder); el2('pl-close').onclick = close; // Womit gerade gespielt wird — und was die Auswahl hergibt. @@ -1956,7 +1979,8 @@ async function startStream(item, v, fail, lauf) { if (lauf.stelle > 1) v.currentTime = lauf.stelle; v.play(); }, { once: true }); - wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id }); + lauf.sitzung = { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id }; + wireProgress(v, lauf.sitzung); if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; } @@ -2019,7 +2043,15 @@ async function playbackInfo(item, lauf = {}) { Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web für immer eine leere Reihe. Alle zehn Sekunden genügt. */ function wireProgress(video, session) { - const send = (path, extra = {}) => { + // **Die zuletzt bekannte Stelle, getrennt vom Element gehalten.** + // + // Das ist der Kern der Sache: Beim Schließen wird das Video abgeräumt, und + // dabei fällt `currentTime` auf null — *bevor* die Stopp-Meldung hinausgeht. + // Jellyfin bekam dann „gestoppt bei 0:00" und löschte den Fortschritt. Der + // Film war gesehen und stand trotzdem nicht unter „Weiterschauen". + session.stelle = 0; + + const send = (path, stelle = session.stelle, extra = {}) => { // Absichtlich ohne await: eine verlorene Meldung kostet ein paar Sekunden // Fortschritt. Dafür mitten im Film einen Fehler zu zeigen wäre die // schlechtere Hälfte des Tauschs. @@ -2028,23 +2060,35 @@ function wireProgress(video, session) { body: JSON.stringify({ ItemId: session.itemID, PlaySessionId: session.id, MediaSourceId: session.sourceID, - PositionTicks: Math.round(video.currentTime * 10_000_000), ...extra, + PositionTicks: Math.round(stelle * 10_000_000), + // Jellyfin ordnet die Meldung sonst keiner Art von Wiedergabe zu und + // verwirft sie stillschweigend. + PlayMethod: 'Transcode', CanSeek: true, + ...extra, }), }).catch(() => {}); }; + session.melden = send; video.addEventListener('playing', () => send('/Sessions/Playing'), { once: true }); let last = 0; video.addEventListener('timeupdate', () => { + if (video.currentTime > 0) session.stelle = video.currentTime; if (video.currentTime - last < 10) return; last = video.currentTime; - send('/Sessions/Playing/Progress', { IsPaused: video.paused }); + send('/Sessions/Playing/Progress', session.stelle, { IsPaused: video.paused }); }); - const stop = () => send('/Sessions/Playing/Stopped'); - video.addEventListener('ended', stop); - video.addEventListener('emptied', stop); + // Anhalten und Weiterlaufen sofort melden: wer pausiert und weggeht, hat + // sonst bis zur nächsten Zehn-Sekunden-Marke nichts hinterlassen. + video.addEventListener('pause', () => + send('/Sessions/Playing/Progress', session.stelle, { IsPaused: true })); + video.addEventListener('play', () => + send('/Sessions/Playing/Progress', session.stelle, { IsPaused: false })); + + // Am Ende steht die Stelle nicht mehr zur Verfügung — gemeint ist das Ende. + video.addEventListener('ended', () => send('/Sessions/Playing/Stopped', video.duration || session.stelle)); } /* Eine Adresse mit Anmeldung im Anhang.