Files
jellytau/src/lib/components/player/VideoPlayer.svelte
T
dtourolle d54d8cc7c4 refactor(logging): route frontend console calls through the logger
TRACES: | DR-204

484 ungated `console.*` calls across 63 non-test frontend files shipped to
end users with no way to turn them off. Mechanical substitution, no control
flow, error handling or message semantics changed:

  console.log / console.debug -> log.debug
  console.info                -> log.info
  console.warn                -> log.warn
  console.error               -> log.error

Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope
now carries them; scope names that already existed are preserved verbatim
(`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename
where a file had none. `src/routes/player/[id]/+page.svelte` keeps its
`NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than
flattening them into the page scope.

`grep -rn 'console\.' src/` now matches nothing outside the tests and the
facade itself.
2026-08-20 19:29:59 +02:00

2790 lines
117 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, StreamingQuality } 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 { fatalNetworkErrorAction } from "./hlsRecovery";
import {
subtitleStreamsOf,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playbackDuration, 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 { nativeSignalRevealsVideo } from "./mediaReady";
import { shouldHideControls } from "./controlsVisibility";
import {
isPipSupported,
enterPip,
setAutoEnterEnabled,
setHtml5VideoState,
} from "$lib/utils/pictureInPicture";
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
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,
planHandoffReturn,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("VideoPlayer");
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?.();
}
/**
* Keep native's picture-in-picture state in step with the `<video>` element.
*
* PiP is driven by the Activity, and it only ever knew about the native
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
* time defaulted to off. So in the then-shipping configuration nothing
* satisfied its "is a video playing?" check and the PiP button did nothing at
* all. Reporting the element gives it a surface it can shrink into, and still
* has to: the flag defaults to on now (DR-161) but a user who turns it off is
* back on the element. (UR-041, DR-160, DR-161)
*/
function reportPipVideoState() {
if (!useHtml5Element || !videoElement) {
setHtml5VideoState(false, 0, 0, false);
return;
}
setHtml5VideoState(
true,
videoElement.videoWidth,
videoElement.videoHeight,
isPlaying
);
}
let isFullscreen = $state(false);
let showControls = $state(true);
/**
* True while the Activity is in picture-in-picture.
*
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
* itself down to the video — controls, header and gradients would otherwise be
* rendered into a window a couple of inches wide. (UR-041, DR-160)
*/
let isInPip = $state(false);
let pipListenerCleanup: (() => void) | null = null;
let showSleepTimerModal = $state(false);
let isBuffering = $state(false);
// Bumped by every reveal so the auto-hide effect restarts its countdown even
// when no other input to that decision changed (a tap during playback).
let lastControlsInteraction = $state(0);
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);
// Streaming bandwidth ceiling. The ladder and the current value both come from
// Rust — the frontend never encodes what a step means.
// TRACES: UR-074 | DR-162
let showQualityMenu = $state(false);
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
let selectedQuality = $state<StreamingQuality>("original");
let changingQuality = $state(false);
// 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;
});
// The audio tracks available for this item, as the server described them.
//
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
// item itself, in `MediaStreams`, which the backend asks for by name in
// `get_item`'s `Fields=` list and tags with an opaque `kind`. Selecting by
// that tag rather than by Jellyfin's `Type` string keeps the taxonomy on the
// Rust side of the boundary.
//
// TRACES: UR-021 | IR-016, JA-009 | DR-024
const audioTracks = $derived(() => {
if (!media || !media.mediaStreams) {
log.debug("No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
log.debug("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) {
log.debug("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) {
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];
log.debug("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) {
log.debug("Loaded series audio preference:", preference);
const matchedIndex = findBestAudioTrack(preference);
if (matchedIndex !== null) {
selectedAudioTrackIndex = matchedIndex;
log.debug("Applied series audio preference, track index:", matchedIndex);
}
}
} catch (err) {
log.warn("Failed to load series audio preference:", err);
}
}
// The subtitle streams the menu offers — the same list the <track> children
// and the native play request are built from, so the menu can never name a
// track the player was never given. subtitleStreamsOf() also drops the ones
// the backend says it cannot deliver as a sidecar (image-based PGS/DVD/DVB,
// which only server burn-in could show and we never ask for — DR-176).
// TRACES: UR-020 | DR-176 | UT-168
const subtitleTracks = $derived(() => {
if (!media || !media.mediaStreams) {
log.debug("No media or mediaStreams available for subtitles");
return [];
}
const tracks = subtitleStreamsOf(media.mediaStreams);
log.debug("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;
}
});
// Auto-hide the control bar.
//
// An `$effect` rather than a timer armed by input, because the conditions that
// *permit* hiding arrive on their own schedule. The first attempt armed a
// one-shot timer from `revealControls()` on entry; three seconds later
// playback had not started yet, `shouldHideControls` correctly declined, and
// nothing re-armed it — so the bar sat over the video for the whole film. The
// timer has to follow the state, not the input event.
//
// Re-runs whenever any input changes: each run cancels the previous timer, so
// starting playback, closing a menu or finishing a seek re-arms it, and
// pausing or opening a menu cancels it. `lastControlsInteraction` is read so a
// tap restarts the countdown even when nothing else changed.
//
// TRACES: UR-003, UR-066 | DR-189 | UT-188
$effect(() => {
void lastControlsInteraction;
if (!showControls) return;
if (
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
})
) {
return;
}
const timer = setTimeout(() => {
showControls = false;
}, 3000);
return () => clearTimeout(timer);
});
// Reveal the video on the native path.
//
// The poster/title card is opaque and covers the whole video area, so on this
// path it is the only thing between the viewer and the ExoPlayer surface —
// every other markMediaReady() call site is a `<video>` element event, and
// there is no `<video>` here.
//
// Driven from the same stores as the seek bar above, deliberately: the
// `player://position-update` and `player://state-changed` channels the native
// branch subscribes to are **never emitted by the backend** (see the comment
// on the effect above — the seek bar had to be moved off them for the same
// reason). Hooking the reveal to those channels looks right, passes a test
// that fires them by hand, and does nothing on a device.
//
// TRACES: UR-003, UR-004 | DR-182 | UT-185
$effect(() => {
if (useHtml5Element || isMediaReady) return;
const state = $playerState.kind;
const position = $playbackPosition;
const duration = $playbackDuration;
if (
nativeSignalRevealsVideo({ kind: "state", state }) ||
nativeSignalRevealsVideo({ kind: "position", position, duration })
) {
markMediaReady();
}
});
// Mirror the backend's play/pause into the UI on the native path.
//
// `isPlaying` is assigned once from the player_play_item response and then
// only by the `player://state-changed` listener — a channel the backend never
// emits, exactly as for the reveal above. So on the native path it was
// whatever the initial response said, forever: with ExoPlayer playing, the UI
// still believed it was paused, which raised the `bg-black/30` play overlay
// over the video surface and left the transport button showing ▶. The video
// was both dimmed and covered while it played.
//
// The player is the authoritative source of playback state and the UI is a
// consumer of it (see the architecture docs), so this reads the same store
// `playerEvents.ts` feeds rather than tracking it locally. HTML5 keeps its own
// element-event wiring, which is authoritative for that path.
//
// TRACES: UR-003, UR-005 | DR-186 | UT-187
$effect(() => {
if (useHtml5Element) return;
isPlaying = $playerState.kind === "playing";
});
// 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) {
log.debug('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;
log.debug('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, () => {
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, () => {
log.debug('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) {
log.warn('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) => {
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
// here identically and only the position tells them apart.
// `currentTime` is already absolute — see hlsRecovery.ts.
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hlsFatalRecoveryAttempts++;
switch (fatalNetworkErrorAction({
positionSeconds: currentTime,
knownDurationSeconds: knownDuration,
attempts: hlsFatalRecoveryAttempts,
})) {
case 'ended':
log.debug('Fatal network error near end of stream - treating as ended');
notifyEnded();
break;
case 'retry':
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad();
break;
case 'giveUp':
log.error('Fatal network error, max recovery attempts reached');
hls!.destroy();
break;
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
log.error('Fatal media error, trying to recover');
hls!.recoverMediaError();
break;
default:
log.error('Unrecoverable HLS error');
hls!.destroy();
break;
}
}
});
}, 50); // 50ms delay to ensure cleanup completes
// Cleanup on effect re-run
return () => {
log.debug('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)
log.debug('Using native HLS support');
videoElement.src = currentStreamUrl;
} else {
// Not an HLS stream, use regular video element
log.debug('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;
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
// DIAGNOSTIC: Check if video has audio tracks
if ((videoElement as any).audioTracks) {
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;
log.debug("Selected default audio track:", selectedAudioTrackIndex);
}
}
if ((videoElement as any).mozHasAudio !== undefined) {
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
}
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
log.debug("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(() => {
log.debug("Initial position changed, seeking to:", pos);
lastAppliedInitialPosition = pos;
if (videoElement) {
videoElement.currentTime = pos;
currentTime = pos;
}
});
});
// Populate the quality menu. Deliberately its own *synchronous* onMount that
// fires the load without awaiting it: an await inside the main onMount below
// flips the component into HTML5 mode and breaks native seeking, and nothing
// about playback waits on this list.
//
// TRACES: UR-074 | DR-162
onMount(() => {
Promise.all([
commands.playerGetStreamingQualities(),
commands.playerGetVideoSettings(),
])
.then(([qualities, settings]) => {
streamingQualities = qualities;
// Optional on the wire (serde default) — absent means uncapped.
selectedQuality = settings.streamingQuality ?? "original";
})
.catch((err) => {
log.warn("Failed to load streaming qualities:", err);
});
});
// 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));
}
// The video player owns the whole screen, so the system bars go away with it
// — not only when the fullscreen button is pressed, which was the sole
// caller of enterImmersive(). The status and navigation bars stayed painted
// over the player on entry, and on the native path they sit directly on top
// of the ExoPlayer surface, which fills the content view.
//
// Synchronous, before any await, per the native-mode pitfall above. Paired
// with the unconditional exitImmersive() in onDestroy. (UR-066, DR-187)
enterImmersive();
// Arm the control-bar auto-hide on entry. Without this the bar only ever
// hides after the first pointer/touch event, which on a touchscreen meant
// "after the user happens to tap" — and before DR-189 wired touch up, never.
revealControls();
// Initialize player via Rust - Rust will decide which backend to use based on platform
if (media && currentStreamUrl) {
try {
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
// 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))
: [];
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
// 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;
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
// 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) {
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
useHtml5Element = true;
try {
await commands.playerStop();
didStopBackendEarly = true;
} catch (err) {
log.warn("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.
log.debug("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 {
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) {
log.warn("Failed to stop backend player:", err);
}
} else if (useHtml5Element && needsTranscoding) {
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
}
// 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);
// The native (ExoPlayer) path has no <video> element, so `canplay`
// never fires and the handleCanPlay initial-seek below never runs —
// resume-at-position played from the beginning on Android. Hand the
// resume point to the adapter, which issues the backend seek.
//
// HTML5 keeps its existing element-driven seek: seeking before the
// element has metadata is clamped back to 0, which is precisely what
// handleCanPlay waits for.
// TRACES: UR-005 | DR-004, DR-028
if (!useHtml5Element) {
hasPerformedInitialSeek = true; // native path owns the initial seek
lastAppliedInitialPosition = initialPosition;
await playerAdapter.load(currentStreamUrl, {
mediaId: media.id,
mediaSourceId: mediaSourceId ?? null,
needsTranscoding,
initialPosition: initialPosition ?? 0,
isLive,
audioTrackIndex: null,
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
// ExoPlayer already received these as SubtitleConfigurations via
// player_play_item; mapped to the adapter shape for the contract.
subtitleTracks: sentSubtitleTracks.map((t) => ({
index: t.streamIndex,
url: t.url,
language: t.srclang,
label: t.label,
mimeType: "text/vtt",
})),
});
if (initialPosition && initialPosition > 0 && !isLive) {
currentTime = initialPosition;
}
}
}
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) {
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.
log.warn("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();
// PiP: keep native's view of the `<video>` current, and react to the window
// shrinking. The listeners are torn down in onDestroy. (DR-160)
reportPipVideoState();
const onPipEntered = () => (isInPip = true);
const onPipExited = () => (isInPip = false);
const onPipPlay = () => void videoElement?.play().catch(() => {});
const onPipPause = () => videoElement?.pause();
window.addEventListener("jellytau-pip-entered", onPipEntered);
window.addEventListener("jellytau-pip-exited", onPipExited);
window.addEventListener("jellytau-pip-play", onPipPlay);
window.addEventListener("jellytau-pip-pause", onPipPause);
pipListenerCleanup = () => {
window.removeEventListener("jellytau-pip-entered", onPipEntered);
window.removeEventListener("jellytau-pip-exited", onPipExited);
window.removeEventListener("jellytau-pip-play", onPipPlay);
window.removeEventListener("jellytau-pip-pause", onPipPause);
};
// 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);
mirrorElementStateToRust(false);
}
}, 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.
log.debug(
`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();
// Same reasoning for the system bars: they belong to the Activity, not to
// this component, so a player torn down while immersive would leave every
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
exitImmersive();
// The `<video>` is going away, so PiP must stop being offered over it.
setHtml5VideoState(false, 0, 0, false);
pipListenerCleanup?.();
pipListenerCleanup = null;
// 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) {
log.debug("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 {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("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() {
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();
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;
log.debug("Setting videoDuration to:", newDuration);
videoDuration = newDuration;
log.debug("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(() => {
log.debug("Derived duration value:", duration);
log.debug("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) {
log.error("Failed to resume after background audio:", err);
}
};
if (el.readyState >= 1 /* HAVE_METADATA */) {
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
await doSeek();
} else {
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
}
return true;
}
function markMediaReady() {
if (isMediaReady) return;
log.debug("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)
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;
log.debug("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) {
log.debug("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) {
log.error("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
log.error("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})`;
log.error("Error interpretation:", msg);
// Log additional debugging info
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"];
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"];
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
}
function handleWaiting() {
log.debug("waiting event - buffering");
isBuffering = true;
}
function handlePlaying() {
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.
markMediaReady();
}
function handleLoadStart() {
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) {
clearTimeout(canplayFallbackTimeout);
}
// Set up a fallback timeout in case canplay event never fires
canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement) {
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
log.debug("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) {
log.warn("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);
}
});
/**
* Mirror the **webview element's** play/pause and position into Rust.
*
* Only ever when the element is what renders. `html5_playing` is Rust's record
* of "a webview element is active and in this state", and `toggle_playback`,
* `play` and `pause` all route transport to that element when it is set. So
* reporting it from the native path is not a harmless extra: it hands
* transport authority to an element that does not exist, and every play/pause
* intent is then emitted into the void. That is exactly what made the pause
* button dead on the native path — from the on-screen tap, the control bar,
* and even a direct `player_toggle` invocation — while seek and skip kept
* working, because they decide elsewhere.
*
* This lived in the player route's reporting callbacks, which cannot tell the
* two rendering paths apart and so mirrored unconditionally — including from
* the 10-second progress interval, which is why the flag came back after
* DR-193 cleared it at load. It belongs here, where `useHtml5Element` is
* known.
*
* TRACES: UR-005, UR-003 | DR-195 | UT-189
*/
function mirrorElementStateToRust(paused: boolean) {
if (!useHtml5Element) return;
html5Adapter.reportState(paused ? "paused" : "playing", reportMediaId ?? null);
html5Adapter.reportPosition(currentTime, duration, { force: true });
}
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
// PiP's play/pause action reflects this. (DR-160)
reportPipVideoState();
// 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;
log.debug(
`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
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
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) {
log.error("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 {
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).
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();
}
log.debug("Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
} catch (err) {
log.error("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;
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) {
log.warn("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;
log.debug("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) {
log.error("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();
log.debug("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;
// How to come back depends on which renderer is actually on screen. See
// planHandoffReturn: the webview element resumes off its stream URL, the
// native backend only ever resumes off an explicit load.
const plan = planHandoffReturn({
useHtml5Element,
position: pos,
wasPlaying,
nativeStateKind: get(playerState).kind,
});
pendingForegroundPlay = plan.shouldPlay;
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
if (needsTranscoding && onSeek) {
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
// stream starts at the BEGINNING of the item, not at `pos`: a start
// position on an HLS playlist is copied onto every segment URI and
// rejected with 400 (DR-181). So there is no base to carry — the element
// is seeked to the absolute position on canplay, exactly like a direct
// stream. This previously set seekOffset = pos, which paired with a URL
// that really did start there; leaving it would now display `pos` while
// playing the opening titles.
// TRACES: UR-040, UR-004 | DR-181
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
seekOffset = 0;
currentTime = pos;
pendingForegroundSeek = pos;
} else {
// Direct stream: reload the original URL and seek the element to pos.
targetUrl = streamUrl;
seekOffset = 0;
pendingForegroundSeek = pos;
}
if (plan.target === "native-backend" && media) {
// ExoPlayer has no element and nothing watches the stream URL for it, so
// the URL dance below would restart precisely nothing — which is exactly
// what shipped: the backend came back from the handoff holding no item,
// leaving a black screen with a play overlay stuck at 0:00 and a play
// button that did nothing (there was nothing loaded to play).
//
// Re-issue the same pair the initial load does, in the same order:
// player_play_item hands ExoPlayer the item and its sideloaded subtitle
// configurations (which cannot be added after prepare()), then the
// adapter load carries the resume position. `sentSubtitleTracks` was
// resolved during onMount for this same item, so it is reused rather
// than re-fetched.
//
// TRACES: UR-040, UR-003 | DR-196
currentStreamUrl = targetUrl;
await commands.playerPlayItem({
streamUrl: targetUrl,
title: media.name,
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetUrl, {
mediaId: media.id,
mediaSourceId: mediaSourceId ?? null,
needsTranscoding,
initialPosition: plan.position,
isLive,
audioTrackIndex: selectedAudioTrackIndex ?? null,
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
subtitleTracks: sentSubtitleTracks.map((t) => ({
index: t.streamIndex,
url: t.url,
language: t.srclang,
label: t.label,
mimeType: "text/vtt",
})),
});
currentTime = plan.position;
// The load starts playing; honour a pause taken on the lockscreen.
if (!plan.shouldPlay) {
await playerController.pause();
}
return;
}
// 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) {
log.error("Background-audio return failed:", err);
}
}
// Consumed by handleCanPlay after the <video> reloads on foreground.
let pendingForegroundSeek: number | null = null;
let pendingForegroundPlay = false;
// On Android the Activity owns the system bars, and requestFullscreen() cannot
// reach them — the WebView already spans the window under an edge-to-edge
// Activity, so on its own it left the status and navigation bars painted over
// the video. The native bridge is what actually makes fullscreen full screen;
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
// 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.
log.warn("requestFullscreen rejected:", err);
});
enterImmersive();
isFullscreen = true;
} else {
document.exitFullscreen();
exitImmersive();
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")}`;
}
/**
* Show the control bar and arm its auto-hide.
*
* This used to be `handleMouseMove` and was wired *only* to the container's
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the
* timer was never armed and the bar stayed up for the whole film — hidden in
* plain sight while the native video surface was itself invisible. It is now
* armed on entry and on every touch interaction as well.
*
* TRACES: UR-003, UR-066 | DR-189 | UT-188
*/
function revealControls() {
showControls = true;
lastControlsInteraction = Date.now();
}
// Kept as the mouse entry point; desktop still drives it from pointer motion.
const handleMouseMove = revealControls;
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;
log.debug("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) {
// Through the toggle, not document.exitFullscreen() directly: leaving
// fullscreen also has to restore the system bars and clear the flag.
toggleFullscreen();
} 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;
// Touch is the only input on the platform this player mostly runs on, and
// it is what `mousemove` never covers: show the bar and re-arm its hide.
// (DR-189)
revealControls();
}
/**
* 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) {
log.debug("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();
}
log.debug("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
);
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
}
} catch (err) {
log.warn("Failed to save series audio preference:", err);
}
}
} catch (err) {
log.error("Failed to change audio track:", err);
// Revert to previous track on error
selectedAudioTrackIndex = previousTrackIndex;
}
}
function toggleQualityMenu() {
showQualityMenu = !showQualityMenu;
}
/**
* Re-open the current stream at a different bandwidth ceiling.
*
* The backend owns everything about how that happens — it decides whether the
* caller reloads (HTML5) or it reloads the native backend itself — so this
* only supplies the position to resume at and reverts the selection if the
* switch fails.
*
* TRACES: UR-074 | DR-162
*/
async function selectQuality(quality: StreamingQuality) {
showQualityMenu = false;
if (quality === selectedQuality || changingQuality) return;
const previous = selectedQuality;
selectedQuality = quality;
changingQuality = true;
try {
stopTimeUpdates();
await playerController.setStreamQuality(
quality,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null,
selectedAudioTrackIndex
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
}
log.debug("Streaming quality changed:", quality);
} catch (err) {
log.error("Failed to change streaming quality:", err);
selectedQuality = previous;
} finally {
changingQuality = false;
}
}
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";
log.debug("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) {
log.debug("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);
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
} catch (error) {
log.error("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
data-testid="video-poster"
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
data-testid="play-overlay"
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 || isInPip}
class:pointer-events-none={!showControls || isInPip}
>
<!-- 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}
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
{#if streamingQualities.length > 0}
<div class="relative">
<button
onclick={toggleQualityMenu}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/>
</svg>
</button>
{#if showQualityMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] 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">
Quality
</div>
{#each streamingQualities as [quality, label, detail]}
<button
onclick={() => selectQuality(quality)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
>
<div class="flex flex-col">
<span class="text-sm">{label}</span>
<span class="text-xs text-gray-400">{detail}</span>
</div>
{#if selectedQuality === quality}
<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>