|
|
|
@@ -75,6 +75,9 @@
|
|
|
|
|
planHandoffReturn,
|
|
|
|
|
type BackgroundAudioState,
|
|
|
|
|
} from "./backgroundAudioHandoff";
|
|
|
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
|
|
|
|
|
|
const log = createLogger("VideoPlayer");
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
media: MediaItem | null;
|
|
|
|
@@ -287,11 +290,11 @@
|
|
|
|
|
// TRACES: UR-021 | IR-016, JA-009 | DR-024
|
|
|
|
|
const audioTracks = $derived(() => {
|
|
|
|
|
if (!media || !media.mediaStreams) {
|
|
|
|
|
console.log("[VideoPlayer] No media or mediaStreams available");
|
|
|
|
|
log.debug("No media or mediaStreams available");
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
|
|
|
|
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
|
|
|
|
|
log.debug("Found audio tracks:", tracks.length, tracks);
|
|
|
|
|
return tracks;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -304,7 +307,7 @@
|
|
|
|
|
if (preference.audioTrackDisplayTitle) {
|
|
|
|
|
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
|
|
|
|
if (match) {
|
|
|
|
|
console.log("[VideoPlayer] Matched audio track by display title:", match.displayTitle);
|
|
|
|
|
log.debug("Matched audio track by display title:", match.displayTitle);
|
|
|
|
|
return match.index;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -313,14 +316,14 @@
|
|
|
|
|
if (preference.audioTrackLanguage) {
|
|
|
|
|
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
|
|
|
|
if (match) {
|
|
|
|
|
console.log("[VideoPlayer] Matched audio track by language:", match.language);
|
|
|
|
|
log.debug("Matched audio track by language:", match.language);
|
|
|
|
|
return match.index;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to default track
|
|
|
|
|
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
|
|
|
|
console.log("[VideoPlayer] Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
|
|
|
|
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
|
|
|
|
return defaultTrack.index;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -335,15 +338,15 @@
|
|
|
|
|
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
|
|
|
|
|
|
|
|
|
|
if (preference) {
|
|
|
|
|
console.log("[VideoPlayer] Loaded series audio preference:", preference);
|
|
|
|
|
log.debug("Loaded series audio preference:", preference);
|
|
|
|
|
const matchedIndex = findBestAudioTrack(preference);
|
|
|
|
|
if (matchedIndex !== null) {
|
|
|
|
|
selectedAudioTrackIndex = matchedIndex;
|
|
|
|
|
console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
|
|
|
|
|
log.debug("Applied series audio preference, track index:", matchedIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("[VideoPlayer] Failed to load series audio preference:", err);
|
|
|
|
|
log.warn("Failed to load series audio preference:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -355,11 +358,11 @@
|
|
|
|
|
// TRACES: UR-020 | DR-176 | UT-168
|
|
|
|
|
const subtitleTracks = $derived(() => {
|
|
|
|
|
if (!media || !media.mediaStreams) {
|
|
|
|
|
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
|
|
|
|
log.debug("No media or mediaStreams available for subtitles");
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const tracks = subtitleStreamsOf(media.mediaStreams);
|
|
|
|
|
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
|
|
|
|
log.debug("Found subtitle tracks:", tracks.length, tracks);
|
|
|
|
|
return tracks;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -547,7 +550,7 @@
|
|
|
|
|
if (isHlsStream && Hls.isSupported()) {
|
|
|
|
|
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
|
|
|
|
if (hls) {
|
|
|
|
|
console.log('[VideoPlayer] Cleaning up existing HLS instance');
|
|
|
|
|
log.debug('Cleaning up existing HLS instance');
|
|
|
|
|
// Detach from media element first to stop all audio/video
|
|
|
|
|
hls.detachMedia();
|
|
|
|
|
// Stop loading and flush buffers
|
|
|
|
@@ -571,7 +574,7 @@
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!videoElement) return;
|
|
|
|
|
|
|
|
|
|
console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl);
|
|
|
|
|
log.debug('Creating new HLS instance for:', currentStreamUrl);
|
|
|
|
|
|
|
|
|
|
// Create new HLS instance
|
|
|
|
|
hls = new Hls({
|
|
|
|
@@ -599,14 +602,14 @@
|
|
|
|
|
|
|
|
|
|
// Listen for media attached event
|
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
|
|
|
console.log('[VideoPlayer] HLS.js attached to video element');
|
|
|
|
|
log.debug('HLS.js attached to video element');
|
|
|
|
|
// Load the HLS stream
|
|
|
|
|
hls!.loadSource(currentStreamUrl);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Listen for manifest parsed event
|
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
|
|
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
|
|
|
|
log.debug('HLS manifest parsed, ready to play');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// On the Android WebView the element's own `canplay` may not fire for
|
|
|
|
@@ -623,7 +626,7 @@
|
|
|
|
|
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
|
|
|
|
canplayFallbackTimeout = setTimeout(() => {
|
|
|
|
|
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
|
|
|
|
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
|
|
|
|
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
|
|
|
|
markMediaReady();
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
@@ -633,7 +636,7 @@
|
|
|
|
|
|
|
|
|
|
// Handle errors
|
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
|
|
|
console.error('[VideoPlayer] HLS error:', data);
|
|
|
|
|
log.error('HLS error:', data);
|
|
|
|
|
if (data.fatal) {
|
|
|
|
|
// Is this the stream ending or the stream breaking? Jellyfin's
|
|
|
|
|
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
|
|
|
@@ -650,25 +653,25 @@
|
|
|
|
|
attempts: hlsFatalRecoveryAttempts,
|
|
|
|
|
})) {
|
|
|
|
|
case 'ended':
|
|
|
|
|
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
|
|
|
|
log.debug('Fatal network error near end of stream - treating as ended');
|
|
|
|
|
notifyEnded();
|
|
|
|
|
break;
|
|
|
|
|
case 'retry':
|
|
|
|
|
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
|
|
|
|
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
|
|
|
|
hls!.startLoad();
|
|
|
|
|
break;
|
|
|
|
|
case 'giveUp':
|
|
|
|
|
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
|
|
|
|
log.error('Fatal network error, max recovery attempts reached');
|
|
|
|
|
hls!.destroy();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
|
|
|
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
|
|
|
|
log.error('Fatal media error, trying to recover');
|
|
|
|
|
hls!.recoverMediaError();
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
console.error('[VideoPlayer] Unrecoverable HLS error');
|
|
|
|
|
log.error('Unrecoverable HLS error');
|
|
|
|
|
hls!.destroy();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
@@ -678,7 +681,7 @@
|
|
|
|
|
|
|
|
|
|
// Cleanup on effect re-run
|
|
|
|
|
return () => {
|
|
|
|
|
console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
|
|
|
|
|
log.debug('Effect cleanup: destroying HLS instance');
|
|
|
|
|
if (hls) {
|
|
|
|
|
hls.detachMedia();
|
|
|
|
|
hls.stopLoad();
|
|
|
|
@@ -691,11 +694,11 @@
|
|
|
|
|
};
|
|
|
|
|
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
|
|
// Native HLS support (Safari)
|
|
|
|
|
console.log('[VideoPlayer] Using native HLS support');
|
|
|
|
|
log.debug('Using native HLS support');
|
|
|
|
|
videoElement.src = currentStreamUrl;
|
|
|
|
|
} else {
|
|
|
|
|
// Not an HLS stream, use regular video element
|
|
|
|
|
console.log('[VideoPlayer] Using regular video element for non-HLS stream');
|
|
|
|
|
log.debug('Using regular video element for non-HLS stream');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -704,24 +707,24 @@
|
|
|
|
|
if (videoElement) {
|
|
|
|
|
videoElement.muted = false;
|
|
|
|
|
videoElement.volume = 1.0;
|
|
|
|
|
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
|
|
|
|
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
|
|
|
|
|
|
|
|
|
// DIAGNOSTIC: Check if video has audio tracks
|
|
|
|
|
if ((videoElement as any).audioTracks) {
|
|
|
|
|
console.log("[VideoPlayer] Audio tracks count:", (videoElement as any).audioTracks.length);
|
|
|
|
|
log.debug("Audio tracks count:", (videoElement as any).audioTracks.length);
|
|
|
|
|
|
|
|
|
|
// Set initial audio track (prefer default track)
|
|
|
|
|
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
|
|
|
|
const defaultTrack = audioTracks().find(t => t.isDefault);
|
|
|
|
|
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
|
|
|
|
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
|
|
|
|
log.debug("Selected default audio track:", selectedAudioTrackIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ((videoElement as any).mozHasAudio !== undefined) {
|
|
|
|
|
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
|
|
|
|
|
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
|
|
|
|
|
}
|
|
|
|
|
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
|
|
|
|
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
|
|
|
|
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
@@ -749,7 +752,7 @@
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
untrack(() => {
|
|
|
|
|
console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
|
|
|
|
|
log.debug("Initial position changed, seeking to:", pos);
|
|
|
|
|
lastAppliedInitialPosition = pos;
|
|
|
|
|
if (videoElement) {
|
|
|
|
|
videoElement.currentTime = pos;
|
|
|
|
@@ -775,7 +778,7 @@
|
|
|
|
|
selectedQuality = settings.streamingQuality ?? "original";
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
|
|
|
|
log.warn("Failed to load streaming qualities:", err);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -808,8 +811,8 @@
|
|
|
|
|
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
|
|
|
|
if (media && currentStreamUrl) {
|
|
|
|
|
try {
|
|
|
|
|
console.log("[VideoPlayer] Initializing player for:", media.name);
|
|
|
|
|
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
|
|
|
|
log.debug("Initializing player for:", media.name);
|
|
|
|
|
log.debug("Stream URL:", currentStreamUrl);
|
|
|
|
|
|
|
|
|
|
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
|
|
|
|
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
|
|
|
@@ -827,7 +830,7 @@
|
|
|
|
|
sentSubtitleTracks = mediaSourceId
|
|
|
|
|
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
|
|
|
|
: [];
|
|
|
|
|
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
|
|
|
|
log.debug(`Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
|
|
|
|
|
|
|
|
|
// Call Rust backend to start playback
|
|
|
|
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
|
|
|
@@ -847,7 +850,7 @@
|
|
|
|
|
// Rust tells us which backend it's using
|
|
|
|
|
useHtml5Element = response.useHtml5Element;
|
|
|
|
|
backendChosen = true;
|
|
|
|
|
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
|
|
|
|
log.debug(`Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
|
|
|
|
|
|
|
|
|
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
|
|
|
|
// the user opted into the experimental native path; otherwise fall back
|
|
|
|
@@ -858,13 +861,13 @@
|
|
|
|
|
// just started, or ExoPlayer and the <video> element both decode the
|
|
|
|
|
// same stream and the audio doubles.
|
|
|
|
|
if (!useHtml5Element && !$experimentalNativeVideo) {
|
|
|
|
|
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
|
|
|
|
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
|
|
|
|
|
useHtml5Element = true;
|
|
|
|
|
try {
|
|
|
|
|
await commands.playerStop();
|
|
|
|
|
didStopBackendEarly = true;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
|
|
|
|
log.warn("Failed to stop native backend:", err);
|
|
|
|
|
}
|
|
|
|
|
} else if (!useHtml5Element) {
|
|
|
|
|
// Native path: clear the opaque layers between the viewport and the
|
|
|
|
@@ -872,7 +875,7 @@
|
|
|
|
|
// Paired with disableNativeVideoCompositing() in the teardown path —
|
|
|
|
|
// leaving this on renders the rest of the app over a transparent
|
|
|
|
|
// window.
|
|
|
|
|
console.log("[VideoPlayer] Using native ExoPlayer video surface");
|
|
|
|
|
log.debug("Using native ExoPlayer video surface");
|
|
|
|
|
enableNativeVideoCompositing();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -880,14 +883,14 @@
|
|
|
|
|
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
|
|
|
|
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
|
|
|
|
try {
|
|
|
|
|
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
|
|
|
|
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
|
|
|
|
await commands.playerStop();
|
|
|
|
|
didStopBackendEarly = true; // Track that we stopped the backend
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("[VideoPlayer] Failed to stop backend player:", err);
|
|
|
|
|
log.warn("Failed to stop backend player:", err);
|
|
|
|
|
}
|
|
|
|
|
} else if (useHtml5Element && needsTranscoding) {
|
|
|
|
|
console.log("[VideoPlayer] Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
|
|
|
|
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
|
|
|
|
// Backend is kept running but should not play audio since HTML5 element handles playback
|
|
|
|
|
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
|
|
|
|
}
|
|
|
|
@@ -972,12 +975,12 @@
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to initialize player:", err);
|
|
|
|
|
log.error("Failed to initialize player:", err);
|
|
|
|
|
if (backendChosen) {
|
|
|
|
|
// The backend already accepted the item; a later error (e.g. event
|
|
|
|
|
// subscription) must not silently switch the seek/controls path to
|
|
|
|
|
// HTML5 while the native backend keeps playing.
|
|
|
|
|
console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error");
|
|
|
|
|
log.warn("Backend already initialized - keeping native mode despite error");
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to HTML5 on error
|
|
|
|
|
useHtml5Element = true;
|
|
|
|
@@ -1042,8 +1045,8 @@
|
|
|
|
|
// Flattened to a single string on purpose: the Android WebView console
|
|
|
|
|
// bridge stringifies objects as "[object Object]" in logcat, which made
|
|
|
|
|
// this whole payload useless when diagnosing over adb.
|
|
|
|
|
console.log(
|
|
|
|
|
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
|
|
|
|
log.debug(
|
|
|
|
|
`Debug t=${videoElement.currentTime.toFixed(2)}` +
|
|
|
|
|
` display=${currentTime.toFixed(2)}` +
|
|
|
|
|
` readyState=${videoElement.readyState}` +
|
|
|
|
|
` networkState=${videoElement.networkState}` +
|
|
|
|
@@ -1111,7 +1114,7 @@
|
|
|
|
|
|
|
|
|
|
// Clean up HLS.js instance - prevent dual audio on unmount
|
|
|
|
|
if (hls) {
|
|
|
|
|
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
|
|
|
|
log.debug("Destroying HLS.js instance on unmount");
|
|
|
|
|
hls.detachMedia(); // Detach from video element first
|
|
|
|
|
hls.stopLoad(); // Stop loading and flush buffers
|
|
|
|
|
hls.destroy();
|
|
|
|
@@ -1129,10 +1132,10 @@
|
|
|
|
|
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
|
|
|
|
if (didStartNativePlayback && !didStopBackendEarly) {
|
|
|
|
|
try {
|
|
|
|
|
console.log("[VideoPlayer] Stopping backend player on component unmount");
|
|
|
|
|
log.debug("Stopping backend player on component unmount");
|
|
|
|
|
await commands.playerStop();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to stop backend player:", err);
|
|
|
|
|
log.error("Failed to stop backend player:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1187,20 +1190,20 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLoadedMetadata() {
|
|
|
|
|
console.log("[VideoPlayer] loadedmetadata event");
|
|
|
|
|
log.debug("loadedmetadata event");
|
|
|
|
|
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
|
|
|
|
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
|
|
|
|
reportPipVideoState();
|
|
|
|
|
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
|
|
|
|
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
|
|
|
|
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
|
|
|
|
log.debug("Video element duration:", videoElement?.duration);
|
|
|
|
|
log.debug("Media item runTimeTicks:", media?.runTimeTicks);
|
|
|
|
|
log.debug("Needs transcoding:", needsTranscoding);
|
|
|
|
|
|
|
|
|
|
// For direct streams without runTimeTicks, use video element's duration
|
|
|
|
|
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
|
|
|
|
const newDuration = videoElement.duration;
|
|
|
|
|
console.log("[VideoPlayer] Setting videoDuration to:", newDuration);
|
|
|
|
|
log.debug("Setting videoDuration to:", newDuration);
|
|
|
|
|
videoDuration = newDuration;
|
|
|
|
|
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
|
|
|
|
|
log.debug("videoDuration state is now:", videoDuration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
|
|
|
@@ -1209,8 +1212,8 @@
|
|
|
|
|
|
|
|
|
|
// Use setTimeout to log the derived value after reactive updates
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
console.log("[VideoPlayer] Derived duration value:", duration);
|
|
|
|
|
console.log("[VideoPlayer] Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
|
|
|
|
log.debug("Derived duration value:", duration);
|
|
|
|
|
log.debug("Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1251,15 +1254,15 @@
|
|
|
|
|
el.volume = 1.0;
|
|
|
|
|
if (shouldPlay) await el.play();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
|
|
|
|
log.error("Failed to resume after background audio:", err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
|
|
|
|
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
|
|
|
|
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
|
|
|
|
await doSeek();
|
|
|
|
|
} else {
|
|
|
|
|
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
|
|
|
|
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
|
|
|
|
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
@@ -1267,7 +1270,7 @@
|
|
|
|
|
|
|
|
|
|
function markMediaReady() {
|
|
|
|
|
if (isMediaReady) return;
|
|
|
|
|
console.log("[VideoPlayer] Marking media ready");
|
|
|
|
|
log.debug("Marking media ready");
|
|
|
|
|
isMediaReady = true;
|
|
|
|
|
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
|
|
|
|
void applyPendingForegroundSeek();
|
|
|
|
@@ -1275,14 +1278,14 @@
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
log.debug("canplay event fired - media is ready");
|
|
|
|
|
isMediaReady = true;
|
|
|
|
|
|
|
|
|
|
// Ensure video is unmuted and at max volume (critical for Android)
|
|
|
|
|
if (videoElement) {
|
|
|
|
|
videoElement.muted = false;
|
|
|
|
|
videoElement.volume = 1.0;
|
|
|
|
|
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
|
|
|
|
log.debug("Video unmuted on canplay, volume: 1.0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returning from background audio: resume the <video> at the position native
|
|
|
|
@@ -1294,7 +1297,7 @@
|
|
|
|
|
|
|
|
|
|
// Seek to initial position if resuming playback
|
|
|
|
|
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
|
|
|
|
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
|
|
|
|
log.debug("Seeking to initial position:", initialPosition);
|
|
|
|
|
hasPerformedInitialSeek = true;
|
|
|
|
|
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
|
|
|
|
|
|
|
|
|
@@ -1325,7 +1328,7 @@
|
|
|
|
|
await videoElement.play();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to seek to initial position:", err);
|
|
|
|
|
log.error("Failed to seek to initial position:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1335,7 +1338,7 @@
|
|
|
|
|
const error = video.error;
|
|
|
|
|
|
|
|
|
|
// Log comprehensive error details
|
|
|
|
|
console.error("[VideoPlayer] Video error event:", {
|
|
|
|
|
log.error("Video error event:", {
|
|
|
|
|
code: error?.code,
|
|
|
|
|
message: error?.message,
|
|
|
|
|
networkState: video.networkState,
|
|
|
|
@@ -1354,28 +1357,28 @@
|
|
|
|
|
|
|
|
|
|
const errorCode = error?.code || 0;
|
|
|
|
|
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
|
|
|
|
|
console.error("[VideoPlayer] Error interpretation:", msg);
|
|
|
|
|
log.error("Error interpretation:", msg);
|
|
|
|
|
|
|
|
|
|
// Log additional debugging info
|
|
|
|
|
console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
|
|
|
|
|
console.error("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
|
|
|
|
log.error("Stream URL:", currentStreamUrl);
|
|
|
|
|
log.error("Needs transcoding:", needsTranscoding);
|
|
|
|
|
|
|
|
|
|
// Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
|
|
|
|
|
const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
|
|
|
|
|
console.error("[VideoPlayer] Network state:", networkStates[video.networkState] || video.networkState);
|
|
|
|
|
log.error("Network state:", networkStates[video.networkState] || video.networkState);
|
|
|
|
|
|
|
|
|
|
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
|
|
|
|
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
|
|
|
|
console.error("[VideoPlayer] Ready state:", readyStates[video.readyState] || video.readyState);
|
|
|
|
|
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleWaiting() {
|
|
|
|
|
console.log("[VideoPlayer] waiting event - buffering");
|
|
|
|
|
log.debug("waiting event - buffering");
|
|
|
|
|
isBuffering = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handlePlaying() {
|
|
|
|
|
console.log("[VideoPlayer] playing event - playback resumed");
|
|
|
|
|
log.debug("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.
|
|
|
|
@@ -1383,9 +1386,9 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLoadStart() {
|
|
|
|
|
console.log("[VideoPlayer] loadstart event - starting to load:", currentStreamUrl);
|
|
|
|
|
console.log("[VideoPlayer] Video element readyState:", videoElement?.readyState);
|
|
|
|
|
console.log("[VideoPlayer] Video element networkState:", videoElement?.networkState);
|
|
|
|
|
log.debug("loadstart event - starting to load:", currentStreamUrl);
|
|
|
|
|
log.debug("Video element readyState:", videoElement?.readyState);
|
|
|
|
|
log.debug("Video element networkState:", videoElement?.networkState);
|
|
|
|
|
|
|
|
|
|
// Clear any existing fallback timeout
|
|
|
|
|
if (canplayFallbackTimeout) {
|
|
|
|
@@ -1395,12 +1398,12 @@
|
|
|
|
|
// Set up a fallback timeout in case canplay event never fires
|
|
|
|
|
canplayFallbackTimeout = setTimeout(() => {
|
|
|
|
|
if (!isMediaReady && videoElement) {
|
|
|
|
|
console.warn("[VideoPlayer] canplay event did not fire within 5 seconds");
|
|
|
|
|
console.log("[VideoPlayer] Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
|
|
|
|
log.warn("canplay event did not fire within 5 seconds");
|
|
|
|
|
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
|
|
|
|
|
|
|
|
|
// Check if video is actually ready despite event not firing
|
|
|
|
|
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
|
|
|
|
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
|
|
|
|
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
|
|
|
|
|
markMediaReady();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1430,7 +1433,7 @@
|
|
|
|
|
jrayActors = actors;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("[VideoPlayer] JRay lookup failed:", err);
|
|
|
|
|
log.warn("JRay lookup failed:", err);
|
|
|
|
|
if (token === jrayRequestId) jrayActors = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1508,8 +1511,8 @@
|
|
|
|
|
// reason. Log the element state so an unexplained pause/resume loop can be
|
|
|
|
|
// attributed from an adb capture instead of guessed at.
|
|
|
|
|
const el = videoElement;
|
|
|
|
|
console.log(
|
|
|
|
|
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
|
|
|
|
log.debug(
|
|
|
|
|
`pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
|
|
|
|
` readyState=${el?.readyState}` +
|
|
|
|
|
` networkState=${el?.networkState}` +
|
|
|
|
|
` seeking=${el?.seeking}` +
|
|
|
|
@@ -1553,7 +1556,7 @@
|
|
|
|
|
try {
|
|
|
|
|
await playerController.toggle();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to toggle playback:", err);
|
|
|
|
|
log.error("Failed to toggle playback:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1595,7 +1598,7 @@
|
|
|
|
|
isDraggingSeekBar = false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
|
|
|
|
|
log.debug("Seeking to:", targetTime.toFixed(2));
|
|
|
|
|
|
|
|
|
|
// Optimistic display; the primitive updates currentTime/seekOffset as it
|
|
|
|
|
// completes (reloadSource drives the stream URL via the adapter bridge).
|
|
|
|
@@ -1617,9 +1620,9 @@
|
|
|
|
|
startTimeUpdates();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
|
|
|
|
log.debug("Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Seek failed:", err);
|
|
|
|
|
log.error("Seek failed:", err);
|
|
|
|
|
} finally {
|
|
|
|
|
isSeeking = false;
|
|
|
|
|
isDraggingSeekBar = false;
|
|
|
|
@@ -1654,12 +1657,12 @@
|
|
|
|
|
|
|
|
|
|
function toggleBackgroundAudio() {
|
|
|
|
|
backgroundAudioOn = !backgroundAudioOn;
|
|
|
|
|
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
|
|
|
|
log.debug("Background-audio toggle ->", backgroundAudioOn);
|
|
|
|
|
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
|
|
|
|
// so exactly one background behavior is active.
|
|
|
|
|
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
|
|
|
|
if (!armed) {
|
|
|
|
|
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
|
|
|
|
log.warn("Background audio NOT armed natively (no bridge)");
|
|
|
|
|
}
|
|
|
|
|
setAutoEnterEnabled(!backgroundAudioOn);
|
|
|
|
|
}
|
|
|
|
@@ -1675,7 +1678,7 @@
|
|
|
|
|
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
|
|
|
|
const pos = computeHandoffPosition(currentTime, 0);
|
|
|
|
|
const wasPlaying = isPlaying;
|
|
|
|
|
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
|
|
|
|
log.debug("Background-audio handoff at position:", pos.toFixed(1));
|
|
|
|
|
handoffState = { active: true, wasPlaying };
|
|
|
|
|
try {
|
|
|
|
|
if (!media) return;
|
|
|
|
@@ -1716,7 +1719,7 @@
|
|
|
|
|
videoElement.load();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
|
|
|
|
log.error("Background-audio handoff failed:", err);
|
|
|
|
|
handoffState = { ...initialHandoffState };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1734,7 +1737,7 @@
|
|
|
|
|
try {
|
|
|
|
|
// Absolute position the native audio reached (base offset applied in Rust).
|
|
|
|
|
const pos = await commands.playerExitBackgroundAudio();
|
|
|
|
|
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
|
|
|
|
|
log.debug("Returning from background audio at:", pos.toFixed(1));
|
|
|
|
|
|
|
|
|
|
isMediaReady = false;
|
|
|
|
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
|
|
|
@@ -1837,7 +1840,7 @@
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
currentStreamUrl = targetUrl;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Background-audio return failed:", err);
|
|
|
|
|
log.error("Background-audio return failed:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1856,7 +1859,7 @@
|
|
|
|
|
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
|
|
|
|
// the immersive call below is what matters on Android, so don't let a
|
|
|
|
|
// rejection here abort it.
|
|
|
|
|
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
|
|
|
|
log.warn("requestFullscreen rejected:", err);
|
|
|
|
|
});
|
|
|
|
|
enterImmersive();
|
|
|
|
|
isFullscreen = true;
|
|
|
|
@@ -1906,7 +1909,7 @@
|
|
|
|
|
});
|
|
|
|
|
pendingSeekTarget = newTime;
|
|
|
|
|
|
|
|
|
|
console.log("[VideoPlayer] Relative seek:", {
|
|
|
|
|
log.debug("Relative seek:", {
|
|
|
|
|
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
|
|
|
|
from: currentTime.toFixed(2),
|
|
|
|
|
to: newTime.toFixed(2),
|
|
|
|
@@ -2092,7 +2095,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
|
|
|
|
|
console.log("[VideoPlayer] Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
|
|
|
|
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
|
|
|
|
const previousTrackIndex = selectedAudioTrackIndex;
|
|
|
|
|
selectedAudioTrackIndex = streamIndex;
|
|
|
|
|
showAudioTrackMenu = false;
|
|
|
|
@@ -2113,7 +2116,7 @@
|
|
|
|
|
startTimeUpdates();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("[VideoPlayer] Successfully changed audio track");
|
|
|
|
|
log.debug("Successfully changed audio track");
|
|
|
|
|
|
|
|
|
|
// Save series audio preference for future episodes
|
|
|
|
|
if (media && media.seriesId) {
|
|
|
|
@@ -2132,14 +2135,14 @@
|
|
|
|
|
selectedTrack.language || null,
|
|
|
|
|
streamIndex
|
|
|
|
|
);
|
|
|
|
|
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
|
|
|
|
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("[VideoPlayer] Failed to save series audio preference:", err);
|
|
|
|
|
log.warn("Failed to save series audio preference:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to change audio track:", err);
|
|
|
|
|
log.error("Failed to change audio track:", err);
|
|
|
|
|
// Revert to previous track on error
|
|
|
|
|
selectedAudioTrackIndex = previousTrackIndex;
|
|
|
|
|
}
|
|
|
|
@@ -2177,9 +2180,9 @@
|
|
|
|
|
if (videoElement && !videoElement.paused) {
|
|
|
|
|
startTimeUpdates();
|
|
|
|
|
}
|
|
|
|
|
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
|
|
|
|
log.debug("Streaming quality changed:", quality);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
|
|
|
|
log.error("Failed to change streaming quality:", err);
|
|
|
|
|
selectedQuality = previous;
|
|
|
|
|
} finally {
|
|
|
|
|
changingQuality = false;
|
|
|
|
@@ -2210,7 +2213,7 @@
|
|
|
|
|
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
|
|
|
if (trackStreamIndex === streamIndex && track.track) {
|
|
|
|
|
track.track.mode = "showing";
|
|
|
|
|
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
|
|
|
|
log.debug("Enabled subtitle track:", streamIndex);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -2230,7 +2233,7 @@
|
|
|
|
|
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
|
|
|
|
*/
|
|
|
|
|
async function selectSubtitle(streamIndex: number | null) {
|
|
|
|
|
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
|
|
|
|
log.debug("Selecting subtitle - streamIndex:", streamIndex);
|
|
|
|
|
selectedSubtitleIndex = streamIndex;
|
|
|
|
|
showSubtitleMenu = false;
|
|
|
|
|
|
|
|
|
@@ -2242,9 +2245,9 @@
|
|
|
|
|
try {
|
|
|
|
|
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
|
|
|
|
await commands.playerSetSubtitleTrack(indexToUse);
|
|
|
|
|
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
|
|
|
|
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
|
|
|
|
log.error("Failed to set subtitle track:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|