Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
3135 lines
127 KiB
Svelte
3135 lines
127 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,
|
|
BackgroundAction,
|
|
StreamSelection,
|
|
} 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,
|
|
type BackgroundSignal,
|
|
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";
|
|
import { elementSrcFor, videoLoaderFor } from "$lib/player/streamTransport";
|
|
|
|
const log = createLogger("VideoPlayer");
|
|
|
|
interface Props {
|
|
media: MediaItem | null;
|
|
/**
|
|
* What to play, as the backend decided it: URL, transport, playback kind and
|
|
* the quality ladder for this source. Replaces the bare `streamUrl` string,
|
|
* which forced this component to re-derive the transport by searching for
|
|
* `.m3u8`.
|
|
*
|
|
* TRACES: UR-079 | DR-224, DR-226
|
|
*/
|
|
selection: StreamSelection;
|
|
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,
|
|
selection,
|
|
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).
|
|
// The selection currently loaded. Starts from the prop and is replaced
|
|
// wholesale by a reload (quality change, audio-track switch, transcoded seek)
|
|
// so transport and URL can never disagree.
|
|
// TRACES: UR-079 | DR-224
|
|
let currentSelection = $state<StreamSelection>(untrack(() => selection));
|
|
const currentStreamUrl = $derived(currentSelection.url);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A selection identical to the one loaded, but pointing at a different URL.
|
|
*
|
|
* Used by the paths that swap the stream without re-negotiating — the
|
|
* background-audio handoff and its return. Each states the transport it is
|
|
* moving to rather than letting it be inferred, which is the whole point of
|
|
* DR-224: the audio handoff really is a progressive mp3, and the rebuilt
|
|
* video stream really is an HLS transcode, and neither is knowable from the
|
|
* URL text.
|
|
*
|
|
* TRACES: UR-040, UR-079 | DR-224
|
|
*/
|
|
function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
|
|
// A re-opened stream is a new transcode job; the old session id is stale.
|
|
return { ...currentSelection, url, transport, playSessionId: null };
|
|
}
|
|
|
|
const adapterBridge: Html5ElementBridge = {
|
|
getElement: () => videoElement,
|
|
getSeekOffset: () => seekOffset,
|
|
setSeekOffset: (o) => {
|
|
seekOffset = o;
|
|
},
|
|
setStreamSelection: (sel) => {
|
|
currentSelection = sel;
|
|
},
|
|
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 changingQuality = $state(false);
|
|
/**
|
|
* The device's durable default, shown when the stream is a direct play and so
|
|
* has no rendition of its own to report. Read once from Settings.
|
|
*/
|
|
let defaultQuality = $state<StreamingQuality>("original");
|
|
|
|
/**
|
|
* The rungs to offer for the stream that is playing, straight from the
|
|
* backend (DR-226). Rungs whose ceiling is at or above the source bitrate are
|
|
* dropped: they produce the same bytes as Original, so listing five of them is
|
|
* five ways to spell one choice. Rust decides which those are — this only
|
|
* decides not to draw them.
|
|
*
|
|
* `Original` is always kept; it is the source, never redundant with it.
|
|
*
|
|
* TRACES: UR-070, UR-079 | DR-226, DR-121
|
|
*/
|
|
const qualityOptions = $derived(
|
|
currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
|
|
);
|
|
|
|
/**
|
|
* The rung in force. A transcode reports the rendition it was built against;
|
|
* a direct play has none, because it *is* the source — so it reads as
|
|
* Original rather than as whatever ceiling happens to be set.
|
|
*/
|
|
const selectedQuality = $derived<StreamingQuality>(
|
|
currentSelection.rendition?.quality ??
|
|
(currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
|
|
);
|
|
|
|
/** Human line for what the server is doing with this stream. */
|
|
const playbackKindLabel = $derived(
|
|
currentSelection.playbackKind.type === "directPlay"
|
|
? "Direct play — the original file"
|
|
: currentSelection.playbackKind.type === "directStream"
|
|
? "Direct stream — repackaged, not re-encoded"
|
|
: "Transcoding on the server",
|
|
);
|
|
|
|
// 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 (selection.url !== lastStreamUrlProp) {
|
|
lastStreamUrlProp = selection.url;
|
|
currentSelection = selection;
|
|
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;
|
|
}
|
|
|
|
// The loader comes from the backend's tagged transport, never from the URL.
|
|
// TRACES: UR-079 | DR-224 | UT-213
|
|
const loader = videoLoaderFor(currentSelection, {
|
|
hlsJsSupported: Hls.isSupported(),
|
|
nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
|
|
});
|
|
|
|
if (loader === "hlsjs") {
|
|
// 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 (loader === "nativeHls") {
|
|
// The element parses the playlist itself (Safari/WebKit).
|
|
log.debug("Using native HLS support");
|
|
videoElement.src = currentStreamUrl;
|
|
} else {
|
|
// Progressive or local: the element loads the URL directly.
|
|
log.debug("Using regular video element", currentSelection.transport.type);
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
});
|
|
|
|
// The quality *ladder* now arrives with the stream selection (DR-226), so all
|
|
// this still needs is the device default, for the case where the stream is a
|
|
// direct play and has no rendition of its own.
|
|
//
|
|
// 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 value.
|
|
//
|
|
// TRACES: UR-074, UR-079 | DR-162, DR-226
|
|
onMount(() => {
|
|
commands
|
|
.playerGetVideoSettings()
|
|
.then((settings) => {
|
|
// Optional on the wire (serde default) — absent means uncapped.
|
|
defaultQuality = settings.streamingQuality ?? "original";
|
|
})
|
|
.catch((err) => {
|
|
log.warn("Failed to load the default streaming quality:", 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(onAppBackgrounded));
|
|
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,
|
|
// Carry the negotiated transport onto the queue item so a later seek
|
|
// reads it instead of falling back. TRACES: UR-079 | DR-229
|
|
transport: currentSelection.transport,
|
|
// 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) => {
|
|
// Rust re-opened the same stream (a transcoded seek): the
|
|
// transport is unchanged, only the job behind it.
|
|
currentSelection = selectionAt(u, currentSelection.transport);
|
|
},
|
|
});
|
|
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 };
|
|
// Set when backgrounding paused playback, so foregrounding resumes only
|
|
// what we stopped -- never something the user paused themselves.
|
|
let pausedByBackgrounding = false;
|
|
|
|
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 onAppBackgrounded(signal: BackgroundSignal) {
|
|
// What to do is a domain decision, not a presentation one: it depends on
|
|
// whether the item has a picture to lose, which is Rust's to know. This used
|
|
// to be decided implicitly by Kotlin gating the event on the toggle, which
|
|
// is why the native path -- whose media service keeps playing regardless --
|
|
// ignored the toggle entirely (DR-224).
|
|
let action: BackgroundAction;
|
|
try {
|
|
action = await commands.playerBackgroundAction(
|
|
signal.backgroundAudioArmed,
|
|
signal.inPictureInPicture,
|
|
);
|
|
} catch (e) {
|
|
// Never leave playback in an undefined state because a decision call
|
|
// failed. Continuing is the old behaviour and the safer default: it
|
|
// cannot silently stop something the user is listening to.
|
|
log.warn("Background action lookup failed; leaving playback alone:", e);
|
|
return;
|
|
}
|
|
|
|
log.debug("Background action:", action);
|
|
switch (action) {
|
|
case "keepPlaying":
|
|
return;
|
|
case "pause":
|
|
// The user did not ask for background playback. Remember that WE paused
|
|
// it, so returning to the foreground can resume rather than leaving a
|
|
// video mysteriously stopped.
|
|
pausedByBackgrounding = isPlaying;
|
|
if (isPlaying) await playerController.pause();
|
|
return;
|
|
case "handOffToAudio":
|
|
await enterBackgroundAudioHandoff();
|
|
return;
|
|
}
|
|
}
|
|
|
|
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() {
|
|
// Resume what backgrounding paused, before the handoff check: the pause path
|
|
// and the handoff path are mutually exclusive, and this one leaves no
|
|
// handoff state to unwind. Only resumes if WE paused it -- a user who
|
|
// paused before locking the screen stays paused.
|
|
if (pausedByBackgrounding) {
|
|
pausedByBackgrounding = false;
|
|
try {
|
|
await playerController.play();
|
|
} catch (e) {
|
|
log.warn("Failed to resume after backgrounding:", e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 stream + how the element/offset should be
|
|
// positioned.
|
|
let targetSelection: StreamSelection;
|
|
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
|
|
// Every transcode this app requests is HLS (DR-140).
|
|
targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
|
|
type: "hls",
|
|
});
|
|
seekOffset = 0;
|
|
currentTime = pos;
|
|
pendingForegroundSeek = pos;
|
|
} else {
|
|
// Direct stream: reload the original selection and seek to pos.
|
|
targetSelection = selection;
|
|
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
|
|
currentSelection = targetSelection;
|
|
await commands.playerPlayItem({
|
|
streamUrl: targetSelection.url,
|
|
title: media.name,
|
|
id: media.id,
|
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
|
needsTranscoding,
|
|
// TRACES: UR-079 | DR-229
|
|
transport: targetSelection.transport,
|
|
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
|
});
|
|
didStartNativePlayback = true;
|
|
await playerAdapter?.load(targetSelection.url, {
|
|
mediaId: media.id,
|
|
selection: targetSelection,
|
|
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).
|
|
currentSelection = selectionAt("", targetSelection.transport);
|
|
await Promise.resolve();
|
|
currentSelection = targetSelection;
|
|
} 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.
|
|
*
|
|
* The change applies to this playback alone; the durable Settings default is
|
|
* untouched (DR-225). Nothing is optimistically assigned here: what the picker
|
|
* shows comes from the selection the backend hands back, because what you get
|
|
* is not always what you asked for — a ceiling above the source bitrate is the
|
|
* source, and claiming otherwise is the kind of lie the old picker told.
|
|
*
|
|
* TRACES: UR-074, UR-079 | DR-162, DR-225, DR-226
|
|
*/
|
|
async function selectQuality(quality: StreamingQuality) {
|
|
showQualityMenu = false;
|
|
if (quality === selectedQuality || changingQuality) return;
|
|
|
|
changingQuality = true;
|
|
try {
|
|
stopTimeUpdates();
|
|
const negotiated = await playerController.setStreamQuality(
|
|
quality,
|
|
videoElement ? videoElement.currentTime + seekOffset : null,
|
|
mediaSourceId ?? null,
|
|
selectedAudioTrackIndex,
|
|
);
|
|
// The HTML5 path reloads through the adapter, which already set the new
|
|
// selection via the bridge. The native path reloads inside Rust and
|
|
// returns nothing, so record what was asked for as the ceiling in force.
|
|
if (!negotiated) {
|
|
defaultQuality = quality;
|
|
}
|
|
if (videoElement && !videoElement.paused) {
|
|
startTimeUpdates();
|
|
}
|
|
log.debug("Streaming quality changed:", quality);
|
|
} catch (err) {
|
|
log.error("Failed to change streaming quality:", err);
|
|
} 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={elementSrcFor(currentSelection, {
|
|
hlsJsSupported: Hls.isSupported(),
|
|
nativeHlsSupported: true,
|
|
})}
|
|
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), populated from what this media
|
|
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-226
|
|
-->
|
|
{#if qualityOptions.length > 1}
|
|
<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="px-3 py-2 border-b border-white/20">
|
|
<div class="text-white text-sm font-semibold">Quality</div>
|
|
<!--
|
|
What the server is actually doing. Only knowable now that
|
|
the backend reports it. TRACES: UR-079 | DR-227
|
|
-->
|
|
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
|
</div>
|
|
{#each qualityOptions as option (option.quality)}
|
|
<button
|
|
onclick={() => selectQuality(option.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 ===
|
|
option.quality
|
|
? 'bg-white/20'
|
|
: ''}"
|
|
>
|
|
<div class="flex flex-col">
|
|
<span class="text-sm">{option.label}</span>
|
|
<span class="text-xs text-gray-400">
|
|
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
|
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
|
</span>
|
|
</div>
|
|
{#if selectedQuality === option.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>
|