Files
jellytau/src/lib/components/player/VideoPlayer.svelte
T
dtourolleandClaude Opus 5 e144e62b31 feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend
overrides threw that answer away, so ExoPlayer's video path had never actually
run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off).

The flag is a suppressor, never a promoter: off forces HTML5 even where Rust
says native, so an in-progress spike cannot ship as the default, but it can
never select native where Rust reported HTML5 — Linux cannot composite behind
WebKitGTK, and promoting there would be a black screen.

Two blockers the spec did not anticipate, both in code assumed to be merely
unreachable rather than broken:

- `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was
  always null and `autoAttachSurface()` bailed. The SurfaceView was created and
  wired to ExoPlayer but never added to the view hierarchy — video would have
  decoded to a surface that was never on screen, whatever the webview did.
  This also revives PiP on the video path, which gated on the same flag.
- `createAdapter()` was not the real gate; it is never called in production.
  The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped
  the native backend `player_play_item` had just started. Both sites now route
  through `createAdapter()`.

Compositing needs two independent opaque layers cleared, not one. Clearing only
the page leaves the WebView widget opaque — audio over a black picture, exactly
the symptom the old INTERIM comment described. `videoSurface.ts` toggles both:
the widget background and window drawable from Kotlin, the page backgrounds via
a `data-native-video` attribute keyed by app.css. Transparency lives in
`tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the
playback session so the launcher never shows through the rest of the app.

Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the
player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on
rotation. The mini-player transition remains unverified on device.

Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a
second copy of the Rust cfg gate free to drift from it. `player_get_capabilities`
now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates.

Tests: adapter selection covers the full matrix, including the regression guard
that the flag off beats Rust. Written first and confirmed failing (2 of 7) before
the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00

2322 lines
98 KiB
Svelte

<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts">
import { onMount, onDestroy, tick, untrack } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import type { JRayActor } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import VolumeControl from "./VolumeControl.svelte";
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import {
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import {
createAdapter,
Html5PlayerAdapter,
type PlayerAdapter,
type Html5ElementBridge,
} from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import {
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
isSynthesizedTouchClick,
isControlSurfaceTouch,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
} from "./tapGestures";
import {
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
subscribeAppForegrounded,
} from "$lib/utils/backgroundAudio";
import { platform } from "@tauri-apps/plugin-os";
import {
computeHandoffPosition,
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
interface Props {
media: MediaItem | null;
streamUrl: string;
mediaSourceId?: string; // Media source ID for subtitle URLs
initialPosition?: number; // Position in seconds to seek to after load (for resume)
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
onClose: () => void;
onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
// Reporting callbacks pass the played media's id explicitly so a late
// reportStop (fired from onDestroy during autoplay navigation) is attributed
// to the episode this player actually played, not the next episode whose URL
// is already active on the page.
onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
onReportStart?: (positionSeconds: number, reportId?: string) => void;
onReportStop?: (positionSeconds: number, reportId?: string) => void;
onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
}
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
// navigation) is always attributed to the episode this player played.
// untrack() makes the intent explicit: capture the initial value only.
const reportMediaId = untrack(() => media?.id);
let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false);
let currentTime = $state(0);
// Guards against onEnded() firing more than once per loaded stream. For
// transcoded HLS, both the native `ended` event and the "fatal network error
// near end of stream" recovery path can fire for the same playback, which
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
// sleep-timer episode counter twice). Reset when the stream URL changes.
let endedFired = $state(false);
function notifyEnded() {
if (endedFired) return;
endedFired = true;
onEnded?.();
}
let isFullscreen = $state(false);
let showControls = $state(true);
let showSleepTimerModal = $state(false);
let isBuffering = $state(false);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
let isSeeking = $state(false);
// Capture only the initial streamUrl prop; later prop changes are applied via
// the $effect below (untrack keeps this a one-time snapshot, matching
// reportMediaId above and silencing state_referenced_locally).
let currentStreamUrl = $state(untrack(() => streamUrl));
let hasReportedStart = $state(false);
let progressInterval: ReturnType<typeof setInterval> | null = null;
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
let hasPerformedInitialSeek = $state(false); // Track if we've seeked to initialPosition
let canplayFallbackTimeout: ReturnType<typeof setTimeout> | null = null; // Fallback timeout for canplay event
let isDraggingSeekBar = $state(false); // Track if user is dragging the seek bar
let debugLogInterval: ReturnType<typeof setInterval> | null = null; // Debug logging interval
let rafId: number | null = null; // RequestAnimationFrame ID for smooth time updates
// Touch gesture state
let touchStartX = $state(0);
let touchStartY = $state(0);
let touchStartTime = $state(0);
let tapGestures = createTapGestureState();
// When a touch tap last ran the gesture handler, so the compatibility click
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
let lastTouchTapAt = 0;
let brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
// Target of a skip already requested but not yet reported back by the player,
// so back-to-back double taps chain instead of stacking on a stale position.
let pendingSeekTarget: number | null = null;
let swipeGestureActive = $state(false);
// Whether the in-flight touch belongs to the player surface (and so may be
// read as a tap/swipe gesture) rather than to a control. Set on touchstart,
// cleared on touchend — see handleTouchMove for why a per-gesture flag and not
// just a per-event target check.
let playerGestureActive = false;
// Raised when the user changes the seek bar's value, cleared by whichever
// release signal commits the seek. See handleSeekBarRelease.
let seekCommitArmed = false;
// Backend info from Rust (Rust decides which backend to use based on platform)
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
let backendChosen = false; // playerPlayItem succeeded and told us which backend to use
let nativeUnlisteners: Array<() => void> = []; // raw-channel listeners for native backend mode
// Position updates captured before a native seek can land after it and snap
// the bar back; suppress backend position feeds briefly after each seek
// (same idea as the MPV backend's last_seek_time suppression).
let lastNativeSeekAt = 0;
const NATIVE_SEEK_SETTLE_MS = 1500;
function nativeSeekSettling(): boolean {
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
}
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
let swipeType = $state<"brightness" | null>(null);
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts
// ===== Player adapter (control boundary) =====
// The adapter owns the high-level control contract (play/pause/seek/track).
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
// registers the adapter with the facade so control intents — from UI OR from a
// backend control event (lockscreen/remote/sleep) — reach this element.
// Widened from Html5PlayerAdapter: the native path registers a
// NativePlayerAdapter here. Element-coupled work is guarded by
// `useHtml5Element`, not by narrowing this type.
let playerAdapter: PlayerAdapter | null = null;
function tearDownHls() {
if (hls) {
hls.detachMedia();
hls.stopLoad();
hls.destroy();
hls = null;
}
}
const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement,
getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { seekOffset = o; },
setStreamUrl: (u) => { currentStreamUrl = u; },
destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null,
};
// Audio track selection
let showAudioTrackMenu = $state(false);
let selectedAudioTrackIndex = $state<number | null>(null);
// Subtitle track selection
let showSubtitleMenu = $state(false);
let selectedSubtitleIndex = $state<number | null>(null);
// Track duration from video element (for when media item doesn't have runTimeTicks)
let videoDuration = $state(0);
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
// Fallback to video element duration for direct streams
const duration = $derived.by(() => {
// Explicitly check if durationMs exists and is a valid number
if (media && media.durationMs && media.durationMs > 0) {
return media.durationMs / 1000;
}
// Otherwise use the video element's duration
return videoDuration;
});
// Get available audio tracks from media streams
const audioTracks = $derived(() => {
if (!media || !media.mediaStreams) {
console.log("[VideoPlayer] No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
return tracks;
});
// Function to find best matching audio track based on preference
function findBestAudioTrack(preference: { audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null }) {
const tracks = audioTracks();
if (tracks.length === 0) return null;
// Try to match by display title first
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);
return match.index;
}
}
// Try to match by language
if (preference.audioTrackLanguage) {
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
if (match) {
console.log("[VideoPlayer] 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);
return defaultTrack.index;
}
// Load series audio preference on mount
async function loadSeriesAudioPreference() {
if (!media || !media.seriesId) return;
try {
const userId = auth.getUserId();
if (!userId) return;
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
if (preference) {
console.log("[VideoPlayer] Loaded series audio preference:", preference);
const matchedIndex = findBestAudioTrack(preference);
if (matchedIndex !== null) {
selectedAudioTrackIndex = matchedIndex;
console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
}
}
} catch (err) {
console.warn("[VideoPlayer] Failed to load series audio preference:", err);
}
}
// Get available subtitle tracks from media streams
const subtitleTracks = $derived(() => {
if (!media || !media.mediaStreams) {
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks;
});
// ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
// Resolved asynchronously into state and only then rendered. The URLs come
// from an async command, so they must never be bound to `src` directly — the
// original markup did exactly that and put "[object Promise]" on every track,
// which is why the whole block ended up commented out (and why selecting a
// subtitle did nothing: with no <track> children the element has no
// textTracks for the adapter to switch on).
// TRACES: UR-020 | DR-023 | UT-143, UT-144
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
// The subtitle list actually handed to the native backend at load time
// (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
// *position in this list*, not a Jellyfin stream index — see
// nativeSubtitleArrayIndex. It is written once, from onMount, before the
// play request; it is not derived, because the request is what fixed the
// backend's idea of the track order.
// TRACES: UR-020 | IR-016 | UT-147
let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
// Cross-origin <track> fetches use the media element's CORS setting; see
// videoCrossOriginMode for why this is opt-in and same-origin-only.
const videoCrossOrigin = $derived(
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
);
$effect(() => {
const streams = media?.mediaStreams ?? null;
const itemId = media?.id;
const sourceId = mediaSourceId;
// Native (ExoPlayer) mode renders subtitles itself; the element has none.
if (!useHtml5Element || !itemId || !sourceId) {
renderedSubtitleTracks = [];
return;
}
let cancelled = false;
void (async () => {
const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
if (cancelled) return;
renderedSubtitleTracks = tracks;
// Keep the menu's checkmark and the element's text tracks in agreement:
// a selection that no longer resolves collapses to "Off".
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
selectedSubtitleIndex = selected;
// The <track> children were just (re)created, so re-apply the selection to
// the new TextTrack objects — otherwise a surviving selection shows nothing.
await tick();
if (!cancelled) applySubtitleToElement(selected);
})();
return () => {
cancelled = true;
};
});
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
let lastStreamUrlProp = $state("");
// Update stream URL when prop changes (from parent component, not from internal seeks)
$effect(() => {
// Only reset when the streamUrl prop actually changes from parent
if (streamUrl !== lastStreamUrlProp) {
lastStreamUrlProp = streamUrl;
currentStreamUrl = streamUrl;
seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes
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
}
});
// Sleep-timer expiry pause is now driven by the backend through the player
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
// pause() (see handleControlCommand / the sleep_timer_expired case). This
// removes the component's direct videoElement.pause() reach-in — the backend
// has control authority over the webview element via the adapter boundary.
// Native backend (Android ExoPlayer): drive the seek bar from the player
// store, which is fed by the backend's PositionUpdate events. The legacy
// "player://position-update" raw channel was never emitted by the backend,
// so without this the bar only moves when the user scrubs.
$effect(() => {
const position = $playbackPosition;
if (!useHtml5Element && !isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
currentTime = position;
}
});
// Set up HLS.js for HLS streams
$effect(() => {
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
return;
}
const isHlsStream = currentStreamUrl.includes('.m3u8');
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');
// Detach from media element first to stop all audio/video
hls.detachMedia();
// Stop loading and flush buffers
hls.stopLoad();
// Destroy the instance
hls.destroy();
hls = null;
}
// Clear video element completely to stop any residual playback
// This is critical to prevent dual audio streams
if (videoElement.src) {
videoElement.pause(); // Ensure playback is stopped
videoElement.removeAttribute('src');
videoElement.load(); // Reset the media element and clear all buffers
videoElement.currentTime = 0;
}
// Small delay to ensure cleanup completes before creating new instance
// This prevents race conditions with dual audio
setTimeout(() => {
if (!videoElement) return;
console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl);
// Create new HLS instance
hls = new Hls({
debug: true, // Enable debug logging to diagnose loading issues
enableWorker: true,
lowLatencyMode: false,
// Buffer configuration for smooth playback without gaps
maxBufferLength: 60, // Maximum buffer length in seconds (increased for smoother playback)
maxMaxBufferLength: 120, // Maximum max buffer length in seconds
backBufferLength: 60, // Keep 60 seconds of back buffer to prevent gaps
maxBufferSize: 100 * 1000 * 1000, // 100MB max buffer size
maxBufferHole: 0.5, // Maximum buffer hole tolerance before seeking over it
maxFragLookUpTolerance: 0.25, // Fragment lookup tolerance
// Improve stall recovery
abrEwmaDefaultEstimate: 500000, // Initial bandwidth estimate
abrBandWidthFactor: 0.95, // Conservative bandwidth estimation
abrBandWidthUpFactor: 0.7, // Slower quality upgrades to reduce buffering
// Prevent aggressive buffer eviction
liveSyncDurationCount: 3, // Only for live streams
liveMaxLatencyDurationCount: 10, // Only for live streams
});
// Attach media element
hls.attachMedia(videoElement);
// Listen for media attached event
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log('[VideoPlayer] 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');
});
// 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;
// Handle errors
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('[VideoPlayer] HLS error:', data);
if (data.fatal) {
// Check if we're near the end of the video - if so, this is likely
// end-of-stream rather than a real error. Jellyfin transcoded HLS
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
const effectiveTime = currentTime + seekOffset;
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hlsFatalRecoveryAttempts++;
if (isNearEnd) {
// Near end of stream - treat as natural end, don't restart
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
notifyEnded();
} else if (hlsFatalRecoveryAttempts <= 3) {
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad();
} else {
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
hls!.destroy();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('[VideoPlayer] Fatal media error, trying to recover');
hls!.recoverMediaError();
break;
default:
console.error('[VideoPlayer] Unrecoverable HLS error');
hls!.destroy();
break;
}
}
});
}, 50); // 50ms delay to ensure cleanup completes
// Cleanup on effect re-run
return () => {
console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
if (hls) {
hls.detachMedia();
hls.stopLoad();
hls.destroy();
hls = null;
}
if (videoElement) {
videoElement.pause();
}
};
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
console.log('[VideoPlayer] 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');
}
});
// Ensure video element is unmuted and has max volume when it's bound (critical for Android)
$effect(() => {
if (videoElement) {
videoElement.muted = false;
videoElement.volume = 1.0;
console.log("[VideoPlayer] 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);
// 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);
}
}
if ((videoElement as any).mozHasAudio !== undefined) {
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
}
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
}
}
});
// Handle initial position changes (for resuming the *same* loaded video from a
// different position, e.g. the resume point is re-chosen without a reload).
//
// This must only react to a genuine change of the `initialPosition` prop to a
// value we haven't already applied. The previous version re-fired on the very
// first seek (it also depended on, and wrote, `hasPerformedInitialSeek`), and
// since seeking a transcoded/HLS stream re-buffers and fires `canplay` again,
// the two seek paths ping-ponged forever — the player appeared to pause/resume
// in a loop. We now snapshot the last-applied position and only seek when the
// prop actually moves to a new value, untracking the writes so this effect
// can't re-trigger itself.
let lastAppliedInitialPosition = $state<number | undefined>(undefined);
$effect(() => {
const pos = initialPosition;
if (!pos || pos <= 0 || !isMediaReady) return;
// Only act on a real change to a not-yet-applied position.
if (pos === untrack(() => lastAppliedInitialPosition)) return;
// Skip the very first application; handleCanPlay owns the initial seek.
if (!untrack(() => hasPerformedInitialSeek)) {
lastAppliedInitialPosition = pos;
return;
}
untrack(() => {
console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
lastAppliedInitialPosition = pos;
if (videoElement) {
videoElement.currentTime = pos;
currentTime = pos;
}
});
});
// Set up progress reporting interval
onMount(async () => {
// Background-audio lifecycle listeners MUST be registered synchronously —
// before any await below — per the native-mode pitfall (an await here can
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
// so onDestroy tears them down.
if (backgroundAudioSupported) {
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
}
// 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);
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
// in hand *before* the play request: ExoPlayer sideloads subtitles as
// MediaItem.SubtitleConfigurations, which have to exist before
// prepare() — there is no way to add one to a loaded item afterwards.
//
// Awaiting here is safe despite the native-mode pitfall: that rule is
// about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
// which throw lifecycle_outside_component and used to be misread as an
// init failure. Nothing is registered here, and the background-audio
// subscriptions above already ran synchronously. resolveSubtitleTracks
// fans the requests out in parallel, so this costs one round trip, not
// one per subtitle stream as the old serial loop did.
// TRACES: UR-020 | IR-016, JA-008 | UT-147
sentSubtitleTracks = mediaSourceId
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
: [];
console.log(`[VideoPlayer] 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
// Send minimal video data - no complex serialization to avoid Tauri Android issues
const response: any = await commands.playerPlayItem({
streamUrl: currentStreamUrl,
title: media.name,
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding: needsTranscoding,
// Order matters: player_set_subtitle_track(n) is a position in this
// array. Previously this array was built and then dropped, so
// ExoPlayer got a MediaItem with no subtitles at all.
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
// Rust tells us which backend it's using
useHtml5Element = response.useHtml5Element;
backendChosen = true;
console.log(`[VideoPlayer] 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
// to the webview element, which is what shipped by default.
//
// The flag is a suppressor, never a promoter — see createAdapter(). When
// it is off we must also stop the native backend that player_play_item
// 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");
useHtml5Element = true;
try {
await commands.playerStop();
didStopBackendEarly = true;
} catch (err) {
console.warn("[VideoPlayer] Failed to stop native backend:", err);
}
} else if (!useHtml5Element) {
// Native path: clear the opaque layers between the viewport and the
// ExoPlayer SurfaceView (webview widget background + page background).
// 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");
enableNativeVideoCompositing();
}
// 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 && !didStopBackendEarly) {
try {
console.log("[VideoPlayer] 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);
}
} else if (useHtml5Element && needsTranscoding) {
console.log("[VideoPlayer] 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
}
// Register the adapter with the facade so control intents (UI, or a
// backend lockscreen/remote/sleep event) route to whatever is actually
// rendering. Both paths need one: the native adapter forwards control
// intents to ExoPlayer over IPC.
{
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
});
playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native",
host,
bridge: adapterBridge,
// useHtml5Element is already the resolved decision above, so the
// flag has had its say; pass it through for the invariant check.
experimentalNativeVideo: $experimentalNativeVideo,
});
// No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter);
}
if (!useHtml5Element) {
// Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
isPlaying = (response.state?.kind ?? response.state) === "playing";
// Cleanup happens in the component's top-level onDestroy. Calling
// onDestroy() here — after an await — throws lifecycle_outside_component,
// which the catch below used to misread as an init failure: it flipped
// useHtml5Element to true, so every seek went down the HTML5 path and
// never reached ExoPlayer (the video "seeked" then snapped back).
nativeUnlisteners.push(
await listen("player://position-update", (event: any) => {
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
currentTime = event.payload.position;
}
})
);
nativeUnlisteners.push(
await listen("player://state-changed", (event: any) => {
isPlaying = event.payload.state === "playing";
})
);
}
} catch (err) {
console.error("[VideoPlayer] 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");
} else {
// Fallback to HTML5 on error
useHtml5Element = true;
// For non-transcoded content, try to stop any backend player that might have started
if (!needsTranscoding) {
try {
await commands.playerStop();
didStopBackendEarly = true;
} catch (stopErr) {
// Ignore errors when stopping
}
} else {
// For transcoded content, keep backend for seeking
didStartNativePlayback = true;
}
}
}
}
// Load series audio preference (for TV shows)
await loadSeriesAudioPreference();
// Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely.
if (!isLive) {
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId);
}
}, 10000);
}
// Debug logging every second
debugLogInterval = setInterval(() => {
if (videoElement && isPlaying && !isSeeking) {
const buffered = videoElement.buffered;
const bufferedRanges = [];
for (let i = 0; i < buffered.length; i++) {
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
}
// 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)}` +
` display=${currentTime.toFixed(2)}` +
` readyState=${videoElement.readyState}` +
` networkState=${videoElement.networkState}` +
` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}`
);
}
}, 1000);
});
onDestroy(async () => {
// FIRST, and synchronously: restore the opaque webview/page backgrounds.
//
// This callback is async, so anything after an `await` may run a frame or
// more later. Leaving the window transparent for even that long shows the
// launcher/wallpaper through the app as the player unwinds. Unconditional
// and idempotent — a no-op when compositing was never enabled.
disableNativeVideoCompositing();
// Stop RAF loop
stopTimeUpdates();
// Unregister the adapter from the facade (guarded so we only clear our own).
if (playerAdapter) {
playerController.clearActiveAdapter(playerAdapter);
playerAdapter = null;
}
if (progressInterval) {
clearInterval(progressInterval);
}
if (debugLogInterval) {
clearInterval(debugLogInterval);
}
tapGestures.cancel();
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
doubleTapFeedbackTimeout = null;
}
// Remove native backend event listeners (incl. background-audio lifecycle subs)
for (const unlisten of nativeUnlisteners) {
unlisten();
}
nativeUnlisteners = [];
// Re-assert defaults so this player's background-audio choice can't leak into
// the next one: disarm background audio and restore auto-PiP.
if (backgroundAudioSupported) {
setBackgroundAudioEnabled(false);
setAutoEnterEnabled(true);
}
// Clean up HLS.js instance - prevent dual audio on unmount
if (hls) {
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
hls.detachMedia(); // Detach from video element first
hls.stopLoad(); // Stop loading and flush buffers
hls.destroy();
hls = null;
}
// Stop video element playback
if (videoElement) {
videoElement.pause();
videoElement.src = '';
videoElement.load();
}
// Stop the player when component is destroyed
// Skip if we already stopped the backend early (non-transcoded + HTML5)
if (didStartNativePlayback && !didStopBackendEarly) {
try {
console.log("[VideoPlayer] Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
console.error("[VideoPlayer] Failed to stop backend player:", err);
}
}
// Report stop when component is destroyed (skip for live - no resume tracking)
if (!isLive && onReportStop && currentTime > 0) {
onReportStop(currentTime, reportMediaId);
}
});
// Smooth time updates using requestAnimationFrame (60fps)
function updateTimeLoop() {
if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
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);
}
}
// Keep the loop alive while playing — stopped by handlePause/handleEnded
if (isPlaying) {
rafId = requestAnimationFrame(updateTimeLoop);
} else {
rafId = null;
}
}
// Start/stop RAF loop based on playback state
function startTimeUpdates() {
if (rafId === null) {
rafId = requestAnimationFrame(updateTimeLoop);
}
}
function stopTimeUpdates() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
// Fallback: Update time on timeupdate event (for when RAF isn't running)
function handleTimeUpdate() {
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
const newCurrentTime = seekOffset + videoElement.currentTime;
if (videoElement.readyState >= 2) {
currentTime = newCurrentTime;
}
}
}
function handleLoadedMetadata() {
console.log("[VideoPlayer] loadedmetadata event");
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
console.log("[VideoPlayer] 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);
videoDuration = newDuration;
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);
console.log("[VideoPlayer] Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
}, 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.
// Apply the pending background-audio foreground seek, if any. This MUST run
// no matter which readiness signal fired — on the Android WebView HLS/MSE path
// `canplay` is unreliable and the video is revealed via markMediaReady()
// instead, so gating this on handleCanPlay alone meant the seek was silently
// dropped and the reloaded stream played from its start (resume "started from
// the beginning"). Returns true if a pending seek was consumed.
async function applyPendingForegroundSeek(): Promise<boolean> {
if (pendingForegroundSeek === null || !videoElement) return false;
const seekTo = pendingForegroundSeek;
const shouldPlay = pendingForegroundPlay;
pendingForegroundSeek = null;
pendingForegroundPlay = false;
hasPerformedInitialSeek = true;
const el = videoElement;
// currentTime is only honored once the element has metadata (duration/seekable).
// If it isn't there yet, defer to loadedmetadata rather than seeking into a
// still-empty timeline (which the element clamps back to 0).
const doSeek = async () => {
try {
el.currentTime = seekTo;
// Displayed position is absolute: element time + transcode seekOffset.
// (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos,
// seekTo=0.) Both yield the correct absolute position.
currentTime = seekOffset + seekTo;
el.muted = false;
el.volume = 1.0;
if (shouldPlay) await el.play();
} catch (err) {
console.error("[VideoPlayer] Failed to resume after background audio:", err);
}
};
if (el.readyState >= 1 /* HAVE_METADATA */) {
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
await doSeek();
} else {
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
}
return true;
}
function markMediaReady() {
if (isMediaReady) return;
console.log("[VideoPlayer] Marking media ready");
isMediaReady = true;
// A handoff return can be revealed here (not via canplay) — apply its seek.
void applyPendingForegroundSeek();
}
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");
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");
}
// Returning from background audio: resume the <video> at the position native
// audio reached, restoring the prior play/pause state. Takes precedence over
// the resume-point seek below (which is for a fresh load, not a handoff).
if (await applyPendingForegroundSeek()) {
return;
}
// Seek to initial position if resuming playback
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
// Pause video to prevent autoplay from starting at position 0
const wasPlaying = !videoElement.paused;
videoElement.pause();
try {
videoElement.currentTime = initialPosition;
currentTime = initialPosition;
// Wait for the seek to complete before resuming playback
await new Promise<void>((resolve) => {
const onSeeked = () => {
videoElement?.removeEventListener("seeked", onSeeked);
resolve();
};
videoElement!.addEventListener("seeked", onSeeked);
// Fallback timeout in case seeked event doesn't fire
setTimeout(() => {
videoElement?.removeEventListener("seeked", onSeeked);
resolve();
}, 2000);
});
// Resume playback after seek completes
if (wasPlaying || videoElement.autoplay) {
await videoElement.play();
}
} catch (err) {
console.error("[VideoPlayer] Failed to seek to initial position:", err);
}
}
}
function handleError(e: Event) {
const video = e.target as HTMLVideoElement;
const error = video.error;
// Log comprehensive error details
console.error("[VideoPlayer] Video error event:", {
code: error?.code,
message: error?.message,
networkState: video.networkState,
readyState: video.readyState,
currentSrc: video.currentSrc,
src: video.src,
});
// MediaError codes: 1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED
const errorMessages: Record<number, string> = {
1: "Playback aborted",
2: "Network error while loading video - check server connectivity and CORS headers",
3: "Video decoding failed - codec may not be supported by browser",
4: "Video format not supported - may need transcoding",
};
const errorCode = error?.code || 0;
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
console.error("[VideoPlayer] Error interpretation:", msg);
// Log additional debugging info
console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
console.error("[VideoPlayer] 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);
// 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);
}
function handleWaiting() {
console.log("[VideoPlayer] waiting event - buffering");
isBuffering = true;
}
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() {
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);
// Clear any existing fallback timeout
if (canplayFallbackTimeout) {
clearTimeout(canplayFallbackTimeout);
}
// 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);
// 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");
markMediaReady();
}
}
}, 5000);
}
// ===== JRay "who's on screen" pause overlay (optional plugin) =====
// On pause, ask the JRay Jellyfin plugin which actors are on screen at the
// current timestamp and show them as a tappable overlay. If the plugin isn't
// installed (or has no data for this item), the call resolves to [] and the
// overlay simply doesn't render. Tapping an actor with a resolved Jellyfin
// Person id navigates to that person's library page.
let jrayActors = $state<JRayActor[]>([]);
// Monotonic token so a slow in-flight request can't overwrite a newer pause
// (or a resume that cleared the list).
let jrayRequestId = 0;
async function fetchJrayActors() {
const itemId = media?.id;
if (!itemId) return;
const token = ++jrayRequestId;
const t = currentTime;
try {
const actors = await auth.getRepository().jrayActorsAt(itemId, t);
// Discard if a newer pause/resume happened while we were waiting.
if (token === jrayRequestId) {
jrayActors = actors;
}
} catch (err) {
console.warn("[VideoPlayer] JRay lookup failed:", err);
if (token === jrayRequestId) jrayActors = [];
}
}
function clearJrayActors() {
jrayRequestId++; // invalidate any in-flight request
jrayActors = [];
}
function openJrayActor(actor: JRayActor) {
if (actor.jellyfin_id) {
goto(`/library/${actor.jellyfin_id}`);
}
}
// Drive the JRay overlay off the single `isPlaying` flag so it works for both
// the HTML5 <video> (desktop) and the native ExoPlayer path (Android, which
// updates isPlaying via the player://state-changed event). Fetch when we go
// paused, clear when we resume. untrack() keeps this from re-running on every
// currentTime tick — only isPlaying transitions matter.
$effect(() => {
if (isPlaying) {
untrack(clearJrayActors);
} else if (isMediaReady) {
untrack(fetchJrayActors);
}
});
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);
hasReportedStart = true;
}
}
function handlePause() {
// The element pausing is normally user intent, but a stall, a source change,
// or a competing controller can also do it — and the pause itself carries no
// 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) : "?"}` +
` readyState=${el?.readyState}` +
` networkState=${el?.networkState}` +
` seeking=${el?.seeking}` +
` ended=${el?.ended}` +
` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}`
);
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);
}
}
function handleEnded() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended
// NOTE: do NOT report a "stopped" player state here. Natural end-of-video is
// an autoplay handoff, not a stop: the backend's on_video_playback_ended
// decides whether to advance to the next episode (incl. sleep-timer episode
// counting). Emitting StateChanged{stopped} would flip the player/mode to
// idle mid-handoff and suppress the next-episode auto-advance (pauses at the
// end of an episode instead of continuing). onReportStop below still reports
// progress to Jellyfin; notifyEnded() drives the autoplay decision.
if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId);
}
// Notify parent that video has ended (for next episode popup)
notifyEnded();
}
async function togglePlayPause() {
// Route through the facade → active adapter so the toggle goes through the
// one control boundary (and the adapter reports the resulting element state
// back into Rust). The element's own play/pause handlers update isPlaying.
try {
await playerController.toggle();
} catch (err) {
console.error("[VideoPlayer] Failed to toggle playback:", err);
}
}
function handleSeekBarInput(e: Event) {
const input = e.target as HTMLInputElement;
const targetTime = parseFloat(input.value);
// Update the displayed time immediately for smooth visual feedback
currentTime = targetTime;
// The user has moved the value; the next release must commit it.
seekCommitArmed = true;
}
/**
* Seek-bar released — commit the value the user landed on, at most once.
*
* Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
* dependable: Android's WebView does not reliably fire it for a touch
* interaction on a range input, so the thumb moved to the tapped position but
* the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
* `change` deliver both signals, hence the arm/disarm — whichever arrives
* first commits and the other is a no-op.
*/
function handleSeekBarRelease(e: Event) {
isDraggingSeekBar = false;
if (!seekCommitArmed) return;
seekCommitArmed = false;
const input = (e.currentTarget ?? e.target) as HTMLInputElement;
void commitSeek(parseFloat(input.value));
}
async function commitSeek(rawTarget: number) {
// Clamp strictly inside the media: the range input's max IS the duration, so
// dragging fully right would otherwise request a segment past the media end,
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
const targetTime = clampSeekTarget(rawTarget, duration);
// Set isSeeking immediately to prevent timeupdate from interfering
isSeeking = true;
isDraggingSeekBar = false;
try {
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
// Optimistic display; the primitive updates currentTime/seekOffset as it
// completes (reloadSource drives the stream URL via the adapter bridge).
currentTime = targetTime;
stopTimeUpdates(); // pause RAF while the seek settles
// The BACKEND decides the strategy (in-place vs transcode reload); the
// facade dispatches the matching adapter PRIMITIVE. This is the shared
// decision-in-Rust design — no strategy branch lives here anymore.
lastNativeSeekAt = Date.now();
await playerController.seekVideo(
targetTime,
mediaSourceId ?? null,
selectedAudioTrackIndex ?? null
);
// Resume smooth updates if still playing after the seek settled.
if (videoElement && !videoElement.paused) {
startTimeUpdates();
}
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
} catch (err) {
console.error("[VideoPlayer] Seek failed:", err);
} finally {
isSeeking = false;
isDraggingSeekBar = false;
}
}
// Resolved once at component setup: the PiP bridge is installed by
// MainActivity before the page loads and never changes for the session.
// Synchronous by design - no await in onMount (see VideoPlayer native-mode
// pitfalls: awaiting there flips the component into HTML5 mode).
const pipSupported = isPipSupported();
function handlePictureInPicture() {
enterPip();
}
// ===== Background audio (UR-040, Android) =====
// Keep the video's audio playing when the app is backgrounded/locked by handing
// playback off to the native ExoPlayer audio service; the WebView <video> is
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
//
// Gate on the platform, NOT on the AndroidBackgroundAudio JS-bridge probe.
// The native isSupported() is unconditionally true on Android, but the bridge
// is injected into the WebView asynchronously and races component mount — a
// one-shot bridge probe here comes out false on some loads and, being a const,
// never recovers, so the button vanished on "some videos". platform() is
// available synchronously and is stable. toggleBackgroundAudio() no-ops safely
// if the bridge is momentarily absent.
const backgroundAudioSupported = platform() === "android";
let backgroundAudioOn = $state(false); // v1: default OFF each session
let handoffState: BackgroundAudioState = { ...initialHandoffState };
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
console.log("[VideoPlayer] 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)");
}
setAutoEnterEnabled(!backgroundAudioOn);
}
// App went to background/locked while background-audio is armed: hand off to
// native audio and stop the WebView video decode.
async function enterBackgroundAudioHandoff() {
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
// loop keeps it at seekOffset + element.currentTime, and it survives HLS
// transcode segment resets). Reading videoElement.currentTime directly is
// wrong for transcoded streams (it's the in-segment offset) and can read 0
// 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));
handoffState = { active: true, wasPlaying };
try {
if (!media) return;
// Ask the server for an audio-only stream of this video item (no video
// decode), carrying the selected audio track and resume position.
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
media.id,
mediaSourceId ?? undefined,
pos,
selectedAudioTrackIndex ?? undefined,
);
await commands.playerEnterBackgroundAudio(
{
id: media.id,
title: media.name,
streamUrl: audioUrl,
videoCodec: "aac",
needsTranscoding: false,
// Now-playing metadata so the lockscreen/miniplayer show the item.
artist: media.seriesName ?? null,
primaryImageTag: media.imageId ?? null,
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
// Episode identity so the backend can auto-advance to the next episode
// when this audio-only stream ends while backgrounded (UR-040).
itemType: media.type ?? null,
seriesId: media.seriesId ?? null,
},
pos,
);
// Tear down the WebView <video>/HLS decode AFTER native audio has started,
// so there is never a gap — and exactly one audio source is ever live.
tearDownHls();
if (videoElement) {
videoElement.pause();
videoElement.removeAttribute("src");
videoElement.load();
}
} catch (err) {
console.error("[VideoPlayer] Background-audio handoff failed:", err);
handoffState = { ...initialHandoffState };
}
}
// App returned to foreground: stop native audio, reload the WebView <video> at
// the position native reached, and restore play/pause.
async function exitBackgroundAudioHandoff() {
if (!shouldExitBackgroundAudio(handoffState)) return;
// Read the native player's state BEFORE exiting — the exit stops it. If the
// user hit pause on the lockscreen while backgrounded, that pause must
// survive the return to video rather than being overwritten by whatever the
// <video> was doing when we handed off.
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
handoffState = { ...initialHandoffState };
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));
isMediaReady = false;
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
// post-handoff position. Keep the initial-position change-effect quiescent:
// leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
// to the current prop means the effect sees no "change" and won't fire a
// stale seek back to the original resume point (clobbering the handoff pos).
hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition;
pendingForegroundPlay = wasPlaying;
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
if (needsTranscoding && onSeek) {
// Transcoded HLS can't seek by setting currentTime — the stream must be
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
// The reloaded segment's timeline starts at 0, so seekOffset carries the
// absolute base and the element seeks to 0 (handled on canplay).
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
seekOffset = pos;
currentTime = pos;
pendingForegroundSeek = 0;
} else {
// Direct stream: reload the original URL and seek the element to pos.
targetUrl = streamUrl;
seekOffset = 0;
pendingForegroundSeek = pos;
}
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
// blank it first, then set it on the next microtask so Svelte sees a real
// transition. Without this, assigning the same value is a no-op and the
// player stays stuck on the loading spinner (HLS never re-initialises).
currentStreamUrl = "";
await Promise.resolve();
currentStreamUrl = targetUrl;
} catch (err) {
console.error("[VideoPlayer] Background-audio return failed:", err);
}
}
// Consumed by handleCanPlay after the <video> reloads on foreground.
let pendingForegroundSeek: number | null = null;
let pendingForegroundPlay = false;
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen = true;
} else {
document.exitFullscreen();
isFullscreen = false;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleMouseMove() {
showControls = true;
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
async function seekRelative(seconds: number) {
isSeeking = true;
// The facade seeks by absolute position, so resolve the delta here —
// chaining off a still-in-flight target so rapid double taps accumulate
// instead of all resolving against the same not-yet-updated position.
const newTime = resolveSeekTarget({
delta: seconds,
reportedPosition: currentTime,
duration,
pendingTarget: pendingSeekTarget,
});
pendingSeekTarget = newTime;
console.log("[VideoPlayer] Relative seek:", {
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
from: currentTime.toFixed(2),
to: newTime.toFixed(2),
});
// Same commit path as the seek bar — one place decides how a seek is issued.
try {
await commitSeek(newTime);
} finally {
// The player is authoritative again from here on.
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === " " || e.key === "k") {
e.preventDefault();
togglePlayPause();
} else if (e.key === "f") {
toggleFullscreen();
} else if (e.key === "Escape") {
if (isFullscreen) {
document.exitFullscreen();
} else {
onClose();
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
seekRelative(SEEK_BACKWARD_SECONDS);
} else if (e.key === "ArrowRight") {
e.preventDefault();
seekRelative(SEEK_FORWARD_SECONDS);
}
}
/**
* Walk up from the touch target collecting the tag/attribute pairs
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
*/
function ancestorChain(target: EventTarget | null) {
const chain: Array<{
tag: string;
isPlayerControls?: boolean;
isPlayerSurface?: boolean;
}> = [];
let node = target as HTMLElement | null;
// Bounded walk: controls live a few levels below the player root, and
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
while (node && node.tagName !== "BODY") {
chain.push({
tag: node.tagName ?? "",
isPlayerControls: node.dataset?.playerControls !== undefined,
isPlayerSurface: node.dataset?.playerSurface !== undefined,
});
node = node.parentElement;
}
return chain;
}
// Touch gesture handlers
function handleTouchStart(e: TouchEvent) {
// Taps on the controls belong to those controls. This listener is on the
// container and touch events bubble, so without this a tap on the bottom
// play button would toggle here AND again via the button's own click — the
// two cancelling out and leaving the control apparently dead (DR-098).
if (isControlSurfaceTouch(ancestorChain(e.target))) {
// The move handler must stay out of it too. It reads touchStartX/Y, which
// this early return leaves at the PREVIOUS gesture's values, so a seek-bar
// drag came out as a huge vertical delta: it was mis-read as a brightness
// swipe, which dimmed the screen and fired a spurious play/pause
// "correction" mid-drag (DR-098).
playerGestureActive = false;
return;
}
playerGestureActive = true;
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartTime = Date.now();
const outcome = registerTap(tapGestures, {
x: touch.clientX,
screenWidth: window.innerWidth,
now: Date.now(),
});
// Suppress the compatibility click this touch will synthesize.
lastTouchTapAt = Date.now();
if (outcome.action === "seek") {
e.preventDefault();
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
// leaves the play state as it was (playing keeps playing, paused stays
// paused).
if (outcome.togglePlayPause) togglePlayPause();
return;
}
// First tap: act now. Nothing is deferred, so there is no timer to race the
// compatibility click Android synthesizes after a touch tap (see DR-098).
togglePlayPause();
}
function handleTouchMove(e: TouchEvent) {
// Only a gesture that began on the bare video surface is ours. Re-checking
// the target here would not be enough: the touch that started on a control
// never recorded a start point, so any delta computed here is meaningless.
if (!playerGestureActive) return;
if (!e.touches[0]) return;
const touch = e.touches[0];
const deltaX = touch.clientX - touchStartX;
const deltaY = touch.clientY - touchStartY;
const timeDelta = Date.now() - touchStartTime;
// Minimum movement to register as swipe (50px)
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
// Only on the frame the gesture is first recognised as a swipe — this runs
// on every touchmove, and the correction below must happen exactly once.
if (!swipeGestureActive) {
// The touchstart already toggled play/pause (taps act immediately now),
// so undo it: a swipe must not change the play state. Forget the tap too,
// so it cannot pair with a later tap into a spurious seek.
togglePlayPause();
tapGestures.cancel();
}
swipeGestureActive = true;
// Brightness control on vertical swipe
swipeType = "brightness";
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
const brightnessChange = -deltaY / 300; // Swipe up = brighter
brightness = Math.max(0.3, Math.min(1.7, 1 + brightnessChange));
// Reset touch start for continuous adjustment
touchStartY = touch.clientY;
}
}
function handleTouchEnd(e: TouchEvent) {
playerGestureActive = false;
swipeGestureActive = false;
swipeType = null;
}
/**
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* `handleTouchStart`, so the compatibility click the browser synthesizes after
* a tap must be ignored or every tap toggles twice.
*
* Used by EVERY click target layered over the video, not just the <video>:
* pausing renders the full-screen play overlay, so the synthesized click lands
* on that button instead and would re-toggle straight back to playing.
*/
function handleSurfaceClick(e: MouseEvent) {
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
togglePlayPause();
}
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
seekRelative(seekSeconds);
showDoubleTapFeedback = feedback;
// Hide feedback after animation
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
}
doubleTapFeedbackTimeout = setTimeout(() => {
showDoubleTapFeedback = null;
}, 800);
}
function toggleAudioTrackMenu() {
showAudioTrackMenu = !showAudioTrackMenu;
}
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
console.log("[VideoPlayer] Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
const previousTrackIndex = selectedAudioTrackIndex;
selectedAudioTrackIndex = streamIndex;
showAudioTrackMenu = false;
try {
// The BACKEND decides whether the audio-track switch needs a transcode
// reload; the facade dispatches the resulting adapter PRIMITIVE
// (reloadSource) which runs the invariant dual-audio teardown sequence.
// No strategy branch lives here anymore.
stopTimeUpdates();
await playerController.switchAudioTrack(
streamIndex,
arrayIndex,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
}
console.log("[VideoPlayer] Successfully changed audio track");
// Save series audio preference for future episodes
if (media && media.seriesId) {
try {
const userId = auth.getUserId();
if (!userId) return;
// Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
if (selectedTrack) {
await commands.storageSaveSeriesAudioPreference(
userId,
media.seriesId,
media.serverId ?? "",
selectedTrack.displayTitle || null,
selectedTrack.language || null,
streamIndex
);
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
}
} catch (err) {
console.warn("[VideoPlayer] Failed to save series audio preference:", err);
}
}
} catch (err) {
console.error("[VideoPlayer] Failed to change audio track:", err);
// Revert to previous track on error
selectedAudioTrackIndex = previousTrackIndex;
}
}
function toggleSubtitleMenu() {
showSubtitleMenu = !showSubtitleMenu;
}
/**
* Show exactly one (or no) text track on the HTML5 element. `null` disables
* every track, which is what the menu's "Off" entry means.
*
* TRACES: UR-020 | DR-023
*/
function applySubtitleToElement(streamIndex: number | null) {
if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;
// Disable all text tracks first, so "Off" genuinely turns subtitles off.
for (let i = 0; i < videoElement.textTracks.length; i++) {
videoElement.textTracks[i].mode = "disabled";
}
if (streamIndex === null) return;
// Find the corresponding track element by stream index.
videoElement.querySelectorAll("track").forEach((track) => {
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);
}
});
}
/**
* Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
* index (or `null` for "Off") — the UI speaks stream indices throughout.
*
* The native backend does not: `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
* groups, i.e. the position of the sideloaded subtitle configuration. That
* position is derived from `sentSubtitleTracks` — the exact array sent with
* the play request — and not from the menu's row number, which counts every
* subtitle *stream* including ones whose URL never resolved and so were never
* sideloaded.
*
* TRACES: UR-020 | DR-023, IR-016 | UT-147
*/
async function selectSubtitle(streamIndex: number | null) {
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
selectedSubtitleIndex = streamIndex;
showSubtitleMenu = false;
// For HTML5 video element, update the text tracks
if (useHtml5Element) {
applySubtitleToElement(streamIndex);
} else {
// For native backend (Android), send command to change subtitle track
try {
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
await commands.playerSetSubtitleTrack(indexToUse);
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
} catch (error) {
console.error("[VideoPlayer] Failed to set subtitle track:", error);
}
}
}
// Get subtitle URL for a given stream index
async function getSubtitleUrl(streamIndex: number): Promise<string> {
if (!media || !mediaSourceId) return "";
try {
const repo = auth.getRepository();
return await repo.getSubtitleUrl(media.id, mediaSourceId ?? "", streamIndex, "vtt");
} catch {
return "";
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 flex flex-col z-50"
class:bg-black={useHtml5Element}
style:background-color={!useHtml5Element ? 'transparent' : ''}
onmousemove={handleMouseMove}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
role="application"
aria-label="Video player"
>
<!-- Video -->
<div class="flex-1 flex items-center justify-center relative">
{#if !!useHtml5Element}
<!-- HTML5 video for desktop/non-Android platforms -->
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
autoplay
muted={false}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay}
onplay={handlePlay}
onpause={handlePause}
onended={handleEnded}
onerror={handleError}
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleSurfaceClick}
>
<!--
Subtitles for the HTML5 path. `src` is a resolved string (see
renderedSubtitleTracks); `data-stream-index` is what
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
attribute: a default track auto-shows, which would contradict the
menu opening on "Off".
-->
{#each renderedSubtitleTracks as track (track.streamIndex)}
<track
kind="subtitles"
src={track.url}
srclang={track.srclang}
label={track.label}
data-stream-index={track.streamIndex}
/>
{/each}
</video>
{:else}
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
<!-- Leave this area transparent so video shows through -->
<div class="flex-1"></div>
{/if}
<!-- Title card with loading spinner (Loading state from DR-001) -->
{#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black">
<!-- Poster/Title Card -->
{#if media?.imageId}
<CachedImage
itemId={media.id}
imageType="Primary"
tag={media.imageId}
maxHeight={1080}
alt={media?.name || "Video"}
class="max-w-full max-h-full object-contain"
/>
{:else}
<div class="text-white text-2xl font-semibold px-8 text-center">
{media?.name || "Loading..."}
</div>
{/if}
<!-- Loading spinner overlay -->
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
{/if}
<!-- Double-tap feedback overlays -->
{#if showDoubleTapFeedback === "left"}
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
</svg>
</div>
</div>
{/if}
{#if showDoubleTapFeedback === "right"}
<div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
</svg>
</div>
</div>
{/if}
<!-- Swipe gesture feedback -->
{#if swipeGestureActive && swipeType === "brightness"}
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
<div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" />
</svg>
<div class="flex flex-col">
<span class="text-white text-xs font-medium">Brightness</span>
<div class="w-24 h-1 bg-white/30 rounded-full mt-1">
<div class="h-full bg-white rounded-full" style="width: {((brightness - 0.3) / 1.4) * 100}%"></div>
</div>
</div>
</div>
</div>
{/if}
<!-- Loading overlay for seeking -->
{#if isSeeking}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if !isPlaying}
<!-- Play overlay. Visually this IS the video surface, so it is marked
`data-player-surface`: it must keep participating in tap gestures even
though it is a <button>, or the second tap of a double tap (which lands
here, because the first tap paused and raised this overlay) is
discarded as "a tap on a control" and seeking dies. It still shares the
synthesized-click guard, since it appears exactly when a tap pauses.
See DR-098. -->
<button
data-player-surface
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={handleSurfaceClick}
aria-label="Play"
>
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
{/if}
<!-- JRay "who's on screen" overlay: shown while paused when the JRay plugin
returned actors for the current timestamp. Tapping an actor with a
resolved Jellyfin Person id opens their library page. -->
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
<!-- Offset by the safe-area insets so the card clears the status bar and,
in landscape, the display cutout. (UR-066) -->
<div
class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto"
style:top="calc(1rem + var(--safe-top))"
style:right="calc(1rem + var(--safe-right))"
>
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
<div class="flex flex-col gap-2">
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
{#if actor.jellyfin_id}
<button
class="flex items-center gap-2 text-left text-white text-sm hover:text-blue-300 transition-colors group"
onclick={() => openJrayActor(actor)}
>
<!-- Headshot from the actor's Jellyfin Person item. Falls back to
a placeholder inside CachedImage when no image exists. -->
<CachedImage
itemId={actor.jellyfin_id}
imageType="Primary"
maxWidth={80}
alt={actor.name}
class="w-8 h-8 rounded-full object-cover flex-shrink-0 ring-1 ring-white/20 group-hover:ring-blue-300/60"
/>
<span>{actor.name}</span>
</button>
{:else}
<span class="flex items-center gap-2 text-white/80 text-sm">
<!-- No resolved Jellyfin id → no headshot available. -->
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
{actor.name.slice(0, 1)}
</span>
<span>{actor.name}</span>
</span>
{/if}
{/each}
</div>
</div>
{/if}
</div>
<!-- Controls. `data-player-controls` marks this subtree as interactive so
container-level tap gestures ignore touches here (see DR-098).
The video itself deliberately fills the whole screen (edge-to-edge, under
the cutout), but every interactive control lives in here — so this box,
not the video, carries the safe-area insets. Without them the scrub bar
and the close/fullscreen buttons sit under the Android gesture bar, and
in landscape under the display cutout. (UR-066) -->
<div
data-player-controls
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
style:padding-bottom="calc(1rem + var(--safe-bottom))"
style:padding-left="calc(1rem + var(--safe-left))"
style:padding-right="calc(1rem + var(--safe-right))"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<!-- Title -->
<div class="mb-2">
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
</div>
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
{#if isLive}
<div class="flex items-center gap-2 mb-2">
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
LIVE
</span>
</div>
{:else}
<div class="flex items-center gap-2 mb-2">
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarRelease}
onmousedown={() => isDraggingSeekBar = true}
onmouseup={handleSeekBarRelease}
ontouchstart={() => isDraggingSeekBar = true}
ontouchend={handleSeekBarRelease}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
/>
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button onclick={togglePlayPause} class="text-white hover:text-gray-300" aria-label={isPlaying ? "Pause" : "Play"}>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next Episode -->
{#if hasNext}
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
{/if}
</div>
<div class="flex items-center gap-4">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<div class="relative">
<button
onclick={toggleAudioTrackMenu}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
</button>
<!-- Audio Track Menu -->
{#if showAudioTrackMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
</div>
{#each audioTracks() as track, i}
<button
onclick={() => selectAudioTrack(track.index, i)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex === track.index ? 'bg-white/20' : ''}"
>
<span class="text-sm">
{track.displayTitle || track.language || `Track ${i + 1}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
</span>
{#if selectedAudioTrackIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<div class="relative">
<button
onclick={toggleSubtitleMenu}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"/>
</svg>
</button>
<!-- Subtitle Menu -->
{#if showSubtitleMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
</div>
<!-- Off option -->
<button
onclick={() => selectSubtitle(null)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === null ? 'bg-white/20' : ''}"
>
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</button>
<!-- Subtitle tracks -->
{#each subtitleTracks() as track}
<button
onclick={() => selectSubtitle(track.index)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
>
<div class="flex flex-col">
<span class="text-sm">
{track.displayTitle || track.language || `Track ${track.index}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
{#if track.isForced}
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
{/if}
</span>
{#if track.codec}
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
{/if}
</div>
{#if selectedSubtitleIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Sleep Timer -->
{#if $sleepTimerActive}
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
{:else}
<button
onclick={() => { showSleepTimerModal = true; }}
class="text-white hover:text-gray-300"
aria-label="Sleep timer"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
</button>
{/if}
<!-- Volume Control -->
<VolumeControl size="md" />
<!-- Picture-in-picture (Android only) -->
{#if pipSupported}
<button
onclick={handlePictureInPicture}
class="text-white hover:text-gray-300"
aria-label="Picture in picture"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
</svg>
</button>
{/if}
<!-- Background audio (Android only) — keep audio playing when the app is
backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
{#if backgroundAudioSupported}
<button
onclick={toggleBackgroundAudio}
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
aria-label="Background audio"
aria-pressed={backgroundAudioOn}
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
</svg>
</button>
{/if}
<!-- Fullscreen -->
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
{#if isFullscreen}
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
{:else}
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
{/if}
</svg>
</button>
<!-- Close -->
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
<style>
@keyframes fade-out {
0% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
50% {
opacity: 1;
transform: translateY(-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translateY(-50%) scale(0.9);
}
}
.animate-fade-out {
animation: fade-out 0.8s ease-out forwards;
}
</style>