Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s

This commit is contained in:
2026-07-02 18:13:55 +02:00
parent 6af7f7dcca
commit 1f6977cd01
16 changed files with 653 additions and 101 deletions
+71 -1
View File
@@ -14,6 +14,7 @@
import CachedImage from "../common/CachedImage.svelte";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
interface Props {
media: MediaItem | null;
@@ -216,6 +217,7 @@
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
lastAppliedInitialPosition = undefined; // New stream - forget the previously-applied resume point
endedFired = false; // New stream loaded - allow onEnded to fire again
html5Adapter.resetReporting(); // New stream - clear position-report throttle
}
});
@@ -319,6 +321,25 @@
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
});
// On the Android WebView the element's own `canplay` may not fire for
// MSE-fed HLS, so treat the first buffered fragment as "ready" too.
// This reveals the <video> element (otherwise it stays invisible behind
// the black poster card while audio plays).
hls.on(Hls.Events.FRAG_BUFFERED, () => {
markMediaReady();
});
// The canplay-fallback timeout is normally armed from the element's
// `loadstart` event, but with hls.js the element's `src` is "" and
// `loadstart` may not fire, so arm a backstop here directly.
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
markMediaReady();
}
}, 5000);
// Reset recovery attempts for new HLS instance
hlsFatalRecoveryAttempts = 0;
@@ -490,9 +511,27 @@
backendChosen = true;
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
// INTERIM (until the video-player API refactor lands): always render
// through the webview HTML5 element, including Android. The native
// ExoPlayer SurfaceView sits behind an opaque webview and has never
// actually been visible (an init bug kept the app on the HTML5 path
// since the POC), so true native mode plays audio behind a frozen
// picture. Stop the native backend and let the webview own playback,
// matching Linux behavior and avoiding dual audio.
if (!useHtml5Element) {
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
useHtml5Element = true;
try {
await commands.playerStop();
didStopBackendEarly = true;
} catch (err) {
console.warn("[VideoPlayer] Failed to stop native backend:", err);
}
}
// If using HTML5 element for non-transcoded content, stop the backend player
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
if (useHtml5Element && !needsTranscoding) {
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
try {
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
await commands.playerStop();
@@ -646,6 +685,9 @@
const newCurrentTime = seekOffset + videoElement.currentTime;
if (videoElement.readyState >= 2) {
currentTime = newCurrentTime;
// Feed the Rust controller a throttled position tick (~250ms) so it
// stays the source of truth for HTML5 video without flooding IPC.
html5Adapter.reportPosition(currentTime, duration);
}
}
@@ -695,6 +737,10 @@
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
}
// Tell the Rust controller the media is loaded and its duration (mirrors the
// native MediaLoaded event so the backend has a duration for HTML5 video).
html5Adapter.reportMediaLoaded(duration);
// Use setTimeout to log the derived value after reactive updates
setTimeout(() => {
console.log("[VideoPlayer] Derived duration value:", duration);
@@ -702,6 +748,20 @@
}, 0);
}
// Flip out of the Loading state and reveal the <video> element (which is
// `invisible` and covered by the black poster card until then). Multiple
// signals can legitimately mean "ready": the native `canplay` event, hls.js
// buffering its first fragment, or the element actually reaching `playing`.
// On the Android system WebView the HLS path feeds the element through MSE
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
// canplay-fallback timeout was never armed — audio played while the video
// stayed invisible. Any of these callers now reveals it.
function markMediaReady() {
if (isMediaReady) return;
console.log("[VideoPlayer] Marking media ready");
isMediaReady = true;
}
async function handleCanPlay() {
// Media is ready to play - transition from Loading to Playing state (DR-001)
console.log("[VideoPlayer] canplay event fired - media is ready");
@@ -799,6 +859,9 @@
function handlePlaying() {
console.log("[VideoPlayer] playing event - playback resumed");
isBuffering = false;
// Safety net: if we reached `playing` we are definitely renderable, even if
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
markMediaReady();
}
function handleLoadStart() {
@@ -881,6 +944,10 @@
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
// Mirror the DOM state into the Rust PlayerController so it is the single
// source of truth for HTML5 video (the <video> lives in the webview, which
// Rust cannot observe directly). See html5Adapter.ts.
html5Adapter.reportState("playing", reportMediaId ?? null);
// Report playback start on first play (skip for live - no resume tracking)
if (!isLive && !hasReportedStart && onReportStart) {
onReportStart(currentTime, reportMediaId);
@@ -891,6 +958,8 @@
function handlePause() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
html5Adapter.reportState("paused", reportMediaId ?? null);
html5Adapter.reportPosition(currentTime, duration, { force: true });
// Report progress when paused
if (onReportProgress) {
onReportProgress(currentTime, true, reportMediaId);
@@ -900,6 +969,7 @@
function handleEnded() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended
html5Adapter.reportState("stopped", reportMediaId ?? null);
// Report stop when video ends (skip for live - no resume tracking)
if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId);