chore(format): run prettier over src/ and scripts/

Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
This commit is contained in:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
+326 -152
View File
@@ -100,7 +100,22 @@
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
}
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
let {
media,
streamUrl,
mediaSourceId,
initialPosition,
needsTranscoding = false,
onClose,
onSeek,
onReportProgress,
onReportStart,
onReportStop,
onEnded,
onNext,
hasNext = false,
isLive = false,
}: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -140,12 +155,7 @@
setHtml5VideoState(false, 0, 0, false);
return;
}
setHtml5VideoState(
true,
videoElement.videoWidth,
videoElement.videoHeight,
isPlaying
);
setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
}
let isFullscreen = $state(false);
let showControls = $state(true);
@@ -242,8 +252,12 @@
const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement,
getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { seekOffset = o; },
setStreamUrl: (u) => { currentStreamUrl = u; },
setSeekOffset: (o) => {
seekOffset = o;
},
setStreamUrl: (u) => {
currentStreamUrl = u;
},
destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null,
};
@@ -278,7 +292,6 @@
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
@@ -293,19 +306,22 @@
log.debug("No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
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 }) {
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);
const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
if (match) {
log.debug("Matched audio track by display title:", match.displayTitle);
return match.index;
@@ -314,7 +330,7 @@
// Try to match by language
if (preference.audioTrackLanguage) {
const match = tracks.find(t => t.language === 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;
@@ -322,8 +338,11 @@
}
// 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);
const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
log.debug(
"Using default/first audio track:",
defaultTrack.displayTitle || defaultTrack.language,
);
return defaultTrack.index;
}
@@ -388,7 +407,7 @@
// 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)
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
);
$effect(() => {
@@ -408,7 +427,10 @@
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));
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.
@@ -439,7 +461,6 @@
}
});
// 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
@@ -545,12 +566,12 @@
return;
}
const isHlsStream = currentStreamUrl.includes('.m3u8');
const isHlsStream = currentStreamUrl.includes(".m3u8");
if (isHlsStream && Hls.isSupported()) {
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
if (hls) {
log.debug('Cleaning up existing HLS instance');
log.debug("Cleaning up existing HLS instance");
// Detach from media element first to stop all audio/video
hls.detachMedia();
// Stop loading and flush buffers
@@ -564,7 +585,7 @@
// This is critical to prevent dual audio streams
if (videoElement.src) {
videoElement.pause(); // Ensure playback is stopped
videoElement.removeAttribute('src');
videoElement.removeAttribute("src");
videoElement.load(); // Reset the media element and clear all buffers
videoElement.currentTime = 0;
}
@@ -574,7 +595,7 @@
setTimeout(() => {
if (!videoElement) return;
log.debug('Creating new HLS instance for:', currentStreamUrl);
log.debug("Creating new HLS instance for:", currentStreamUrl);
// Create new HLS instance
hls = new Hls({
@@ -602,14 +623,14 @@
// Listen for media attached event
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
log.debug('HLS.js attached to video element');
log.debug("HLS.js attached to video element");
// Load the HLS stream
hls!.loadSource(currentStreamUrl);
});
// Listen for manifest parsed event
hls.on(Hls.Events.MANIFEST_PARSED, () => {
log.debug('HLS manifest parsed, ready to play');
log.debug("HLS manifest parsed, ready to play");
});
// On the Android WebView the element's own `canplay` may not fire for
@@ -626,7 +647,11 @@
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
log.warn(
"HLS canplay fallback - revealing video (readyState:",
videoElement.readyState,
")",
);
markMediaReady();
}
}, 5000);
@@ -636,7 +661,7 @@
// Handle errors
hls.on(Hls.Events.ERROR, (event, data) => {
log.error('HLS error:', data);
log.error("HLS error:", data);
if (data.fatal) {
// Is this the stream ending or the stream breaking? Jellyfin's
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
@@ -647,31 +672,37 @@
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');
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, ')');
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');
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');
log.error("Fatal media error, trying to recover");
hls!.recoverMediaError();
break;
default:
log.error('Unrecoverable HLS error');
log.error("Unrecoverable HLS error");
hls!.destroy();
break;
}
@@ -681,7 +712,7 @@
// Cleanup on effect re-run
return () => {
log.debug('Effect cleanup: destroying HLS instance');
log.debug("Effect cleanup: destroying HLS instance");
if (hls) {
hls.detachMedia();
hls.stopLoad();
@@ -692,13 +723,13 @@
videoElement.pause();
}
};
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS support (Safari)
log.debug('Using native HLS support');
log.debug("Using native HLS support");
videoElement.src = currentStreamUrl;
} else {
// Not an HLS stream, use regular video element
log.debug('Using regular video element for non-HLS stream');
log.debug("Using regular video element for non-HLS stream");
}
});
@@ -707,7 +738,12 @@
if (videoElement) {
videoElement.muted = false;
videoElement.volume = 1.0;
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
log.debug(
"Video element configured: muted=",
videoElement.muted,
"volume=",
videoElement.volume,
);
// DIAGNOSTIC: Check if video has audio tracks
if ((videoElement as any).audioTracks) {
@@ -715,7 +751,7 @@
// Set initial audio track (prefer default track)
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
const defaultTrack = audioTracks().find(t => t.isDefault);
const defaultTrack = audioTracks().find((t) => t.isDefault);
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
log.debug("Selected default audio track:", selectedAudioTrackIndex);
}
@@ -724,7 +760,10 @@
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
}
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
log.debug(
"webkitAudioDecodedByteCount:",
(videoElement as any).webkitAudioDecodedByteCount,
);
}
}
});
@@ -768,10 +807,7 @@
//
// TRACES: UR-074 | DR-162
onMount(() => {
Promise.all([
commands.playerGetStreamingQualities(),
commands.playerGetVideoSettings(),
])
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
.then(([qualities, settings]) => {
streamingQualities = qualities;
// Optional on the wire (serde default) — absent means uncapped.
@@ -883,14 +919,18 @@
// 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");
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");
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
}
@@ -902,7 +942,9 @@
{
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
onStreamUrlChanged: (u) => {
currentStreamUrl = u;
},
});
playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native",
@@ -966,12 +1008,12 @@
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
currentTime = event.payload.position;
}
})
}),
);
nativeUnlisteners.push(
await listen("player://state-changed", (event: any) => {
isPlaying = event.payload.state === "playing";
})
}),
);
}
} catch (err) {
@@ -1053,7 +1095,7 @@
` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}`
` buffered=${bufferedRanges.join(", ")}`,
);
}
}, 1000);
@@ -1124,7 +1166,7 @@
// Stop video element playback
if (videoElement) {
videoElement.pause();
videoElement.src = '';
videoElement.src = "";
videoElement.load();
}
@@ -1199,7 +1241,12 @@
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) {
if (
videoElement &&
videoElement.duration &&
!isNaN(videoElement.duration) &&
videoElement.duration !== Infinity
) {
const newDuration = videoElement.duration;
log.debug("Setting videoDuration to:", newDuration);
videoDuration = newDuration;
@@ -1262,8 +1309,17 @@
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 });
log.debug(
"Deferring foreground seek until loadedmetadata:",
(seekOffset + seekTo).toFixed(1),
);
el.addEventListener(
"loadedmetadata",
() => {
void doSeek();
},
{ once: true },
);
}
return true;
}
@@ -1368,7 +1424,13 @@
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"];
const readyStates = [
"HAVE_NOTHING",
"HAVE_METADATA",
"HAVE_CURRENT_DATA",
"HAVE_FUTURE_DATA",
"HAVE_ENOUGH_DATA",
];
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
}
@@ -1399,10 +1461,16 @@
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);
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
if (videoElement.readyState >= 3) {
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
markMediaReady();
}
@@ -1519,7 +1587,7 @@
` ended=${el?.ended}` +
` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}`
` handoff=${handoffState.active}`,
);
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
@@ -1612,7 +1680,7 @@
await playerController.seekVideo(
targetTime,
mediaSourceId ?? null,
selectedAudioTrackIndex ?? null
selectedAudioTrackIndex ?? null,
);
// Resume smooth updates if still playing after the seek settled.
@@ -1684,12 +1752,14 @@
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,
);
const audioUrl = await auth
.getRepository()
.getAudioOnlyStreamUrlForVideo(
media.id,
mediaSourceId ?? undefined,
pos,
selectedAudioTrackIndex ?? undefined,
);
await commands.playerEnterBackgroundAudio(
{
id: media.id,
@@ -2110,7 +2180,7 @@
streamIndex,
arrayIndex,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null
mediaSourceId ?? null,
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
@@ -2125,7 +2195,7 @@
if (!userId) return;
// Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
if (selectedTrack) {
await commands.storageSaveSeriesAudioPreference(
userId,
@@ -2133,9 +2203,12 @@
media.serverId ?? "",
selectedTrack.displayTitle || null,
selectedTrack.language || null,
streamIndex
streamIndex,
);
log.debug(
"Saved series audio preference:",
selectedTrack.displayTitle || selectedTrack.language,
);
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
}
} catch (err) {
log.warn("Failed to save series audio preference:", err);
@@ -2175,7 +2248,7 @@
quality,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null,
selectedAudioTrackIndex
selectedAudioTrackIndex,
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
@@ -2245,7 +2318,12 @@
try {
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
await commands.playerSetSubtitleTrack(indexToUse);
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
log.debug(
"Native backend subtitle track changed - streamIndex:",
streamIndex,
"position:",
indexToUse,
);
} catch (error) {
log.error("Failed to set subtitle track:", error);
}
@@ -2269,7 +2347,7 @@
<div
class="fixed inset-0 flex flex-col z-50"
class:bg-black={useHtml5Element}
style:background-color={!useHtml5Element ? 'transparent' : ''}
style:background-color={!useHtml5Element ? "transparent" : ""}
onmousemove={handleMouseMove}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
@@ -2282,44 +2360,44 @@
{#if !!useHtml5Element}
<!-- HTML5 video for desktop/non-Android platforms -->
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
autoplay
muted={false}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay}
onplay={handlePlay}
onpause={handlePause}
onended={handleEnded}
onerror={handleError}
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleSurfaceClick}
>
<!--
bind:this={videoElement}
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
autoplay
muted={false}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay}
onplay={handlePlay}
onpause={handlePause}
onended={handleEnded}
onerror={handleError}
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleSurfaceClick}
>
<!--
Subtitles for the HTML5 path. `src` is a resolved string (see
renderedSubtitleTracks); `data-stream-index` is what
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
attribute: a default track auto-shows, which would contradict the
menu opening on "Off".
-->
{#each renderedSubtitleTracks as track (track.streamIndex)}
<track
kind="subtitles"
src={track.url}
srclang={track.srclang}
label={track.label}
data-stream-index={track.streamIndex}
/>
{/each}
</video>
{#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 -->
@@ -2350,7 +2428,9 @@
<!-- 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
class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
</div>
{/if}
@@ -2360,8 +2440,12 @@
<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>
<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>
@@ -2371,8 +2455,12 @@
<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>
<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>
@@ -2383,12 +2471,17 @@
<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" />
<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
class="h-full bg-white rounded-full"
style="width: {((brightness - 0.3) / 1.4) * 100}%"
></div>
</div>
</div>
</div>
@@ -2398,7 +2491,9 @@
<!-- 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
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
@@ -2454,7 +2549,9 @@
{: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">
<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>
@@ -2506,9 +2603,9 @@
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarRelease}
onmousedown={() => isDraggingSeekBar = true}
onmousedown={() => (isDraggingSeekBar = true)}
onmouseup={handleSeekBarRelease}
ontouchstart={() => isDraggingSeekBar = true}
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
@@ -2522,7 +2619,11 @@
<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"}>
<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" />
@@ -2554,13 +2655,17 @@
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"/>
<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="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
@@ -2568,7 +2673,10 @@
{#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' : ''}"
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}`}
@@ -2577,8 +2685,12 @@
{/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
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>
@@ -2600,12 +2712,16 @@
>
<!-- 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"/>
<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="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Quality
@@ -2613,15 +2729,22 @@
{#each streamingQualities as [quality, label, detail]}
<button
onclick={() => selectQuality(quality)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
quality
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">{label}</span>
<span class="text-xs text-gray-400">{detail}</span>
</div>
{#if selectedQuality === quality}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
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>
@@ -2641,13 +2764,17 @@
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"/>
<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="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
@@ -2655,12 +2782,19 @@
<!-- 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' : ''}"
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
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>
@@ -2668,7 +2802,10 @@
{#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' : ''}"
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">
@@ -2685,8 +2822,12 @@
{/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
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>
@@ -2699,15 +2840,23 @@
<!-- Sleep Timer -->
{#if $sleepTimerActive}
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
<SleepTimerIndicator
onClick={() => {
showSleepTimerModal = true;
}}
/>
{:else}
<button
onclick={() => { showSleepTimerModal = true; }}
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" />
<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}
@@ -2723,7 +2872,9 @@
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" />
<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}
@@ -2733,23 +2884,35 @@
{#if backgroundAudioSupported}
<button
onclick={toggleBackgroundAudio}
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
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" />
<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">
<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" />
<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" />
<path
d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
/>
{/if}
</svg>
</button>
@@ -2757,7 +2920,12 @@
<!-- 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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -2765,7 +2933,13 @@
</div>
</div>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => {
showSleepTimerModal = false;
}}
mediaType={media?.type}
/>
<style>
@keyframes fade-out {