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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user