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:
+242
-110
@@ -8,13 +8,27 @@
|
||||
import type { MediaItem, MediaKind } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
|
||||
import {
|
||||
queue,
|
||||
currentQueueItem,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
hasNext as hasNextStore,
|
||||
hasPrevious as hasPreviousStore,
|
||||
} from "$lib/stores/queue";
|
||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||
import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
|
||||
import {
|
||||
playbackPosition,
|
||||
playbackDuration,
|
||||
currentMedia as storeCurrentMedia,
|
||||
} from "$lib/stores/player";
|
||||
import { get } from "svelte/store";
|
||||
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
||||
import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface";
|
||||
import {
|
||||
shouldReuseActivePlayback,
|
||||
resolvePlayerSurface,
|
||||
} from "$lib/components/player/playerSurface";
|
||||
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
|
||||
import {
|
||||
reportPlaybackStart,
|
||||
@@ -100,7 +114,14 @@
|
||||
const id = itemId;
|
||||
const restart = restartParam;
|
||||
if (id && id !== loadedItemId) {
|
||||
autoPlayLog.debug("$effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
|
||||
autoPlayLog.debug(
|
||||
"$effect triggered: loading new item",
|
||||
id,
|
||||
"(was:",
|
||||
loadedItemId,
|
||||
") restart:",
|
||||
restart,
|
||||
);
|
||||
// restart=true (advancing to next episode) forces start-from-beginning,
|
||||
// bypassing the resume-progress check.
|
||||
loadAndPlay(id, restart ? 0 : undefined, restart);
|
||||
@@ -121,8 +142,7 @@
|
||||
// treat it as video when it carries a video media stream.
|
||||
function isVideoChannelItem(item: MediaItem): boolean {
|
||||
return (
|
||||
item.kind === "channelItem" &&
|
||||
(item.mediaStreams?.some((s) => s.kind === "video") ?? false)
|
||||
item.kind === "channelItem" && (item.mediaStreams?.some((s) => s.kind === "video") ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +160,15 @@
|
||||
currentMedia = item;
|
||||
|
||||
// Check if this is a non-playable collection type that should be viewed in library instead
|
||||
const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
|
||||
const collectionKinds: MediaKind[] = [
|
||||
"album",
|
||||
"artist",
|
||||
"series",
|
||||
"season",
|
||||
"folder",
|
||||
"playlist",
|
||||
"channel",
|
||||
];
|
||||
if (item.kind && collectionKinds.includes(item.kind)) {
|
||||
log.debug("loadAndPlay: Redirecting collection type to library:", item.kind);
|
||||
goto(`/library/${id}`);
|
||||
@@ -150,7 +178,8 @@
|
||||
// Determine if this is video content (Movie, Episode, live TV channels, and
|
||||
// channel leaf items that carry a video stream).
|
||||
isLive = item.kind === "liveChannel";
|
||||
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
|
||||
isVideo =
|
||||
item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
|
||||
|
||||
// If this track is already playing in the backend, just show the UI
|
||||
// without restarting playback (e.g., when expanding from MiniPlayer).
|
||||
@@ -190,7 +219,16 @@
|
||||
// When forceRestart is set (advancing to a next episode) we always start
|
||||
// from the beginning, skipping the resume check and resume dialog.
|
||||
const userId = auth.getUserId();
|
||||
log.debug("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
|
||||
log.debug(
|
||||
"Resume check - userId:",
|
||||
userId,
|
||||
"itemId:",
|
||||
id,
|
||||
"startPosition:",
|
||||
startPosition,
|
||||
"forceRestart:",
|
||||
forceRestart,
|
||||
);
|
||||
|
||||
// Live streams have no fixed position - never resume.
|
||||
if (!startPosition && !forceRestart && userId && !isLive) {
|
||||
@@ -203,7 +241,14 @@
|
||||
const totalSeconds = item.durationMs / 1000;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
log.debug("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
|
||||
log.debug(
|
||||
"Resume check - positionSeconds:",
|
||||
positionSeconds,
|
||||
"totalSeconds:",
|
||||
totalSeconds,
|
||||
"progressPercent:",
|
||||
progressPercent,
|
||||
);
|
||||
|
||||
// Store for later use regardless of whether dialog is shown
|
||||
retrievedProgressSeconds = positionSeconds;
|
||||
@@ -216,10 +261,22 @@
|
||||
loading = false;
|
||||
return; // Wait for user decision
|
||||
} else {
|
||||
log.debug("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
|
||||
log.debug(
|
||||
"Resume check - NOT showing dialog. Position > 30?",
|
||||
positionSeconds > 30,
|
||||
"Progress < 90?",
|
||||
progressPercent < 90,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.debug("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
|
||||
log.debug(
|
||||
"Resume check - No valid progress found. Has progress?",
|
||||
!!progress,
|
||||
"Has position?",
|
||||
progress?.positionMs,
|
||||
"Has runtime?",
|
||||
!!item.durationMs,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("Failed to check saved progress:", e);
|
||||
@@ -232,12 +289,15 @@
|
||||
// Check if this item is downloaded locally
|
||||
const downloadsState = get(downloads);
|
||||
const localDownload = Object.values(downloadsState.downloads).find(
|
||||
(d: DownloadInfo) => d.itemId === id && d.status === "completed"
|
||||
(d: DownloadInfo) => d.itemId === id && d.status === "completed",
|
||||
);
|
||||
|
||||
if (localDownload) {
|
||||
// Use local file for playback
|
||||
log.debug("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
|
||||
log.debug(
|
||||
"loadAndPlay: Found local download, using offline playback:",
|
||||
localDownload.filePath,
|
||||
);
|
||||
isOfflinePlayback = true;
|
||||
|
||||
// Get the storage path and resolve the file's location. A completed
|
||||
@@ -309,7 +369,12 @@
|
||||
|
||||
if (isVideo) {
|
||||
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||
log.debug("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
|
||||
log.debug(
|
||||
"loadAndPlay: Using video stream, directPlay:",
|
||||
playbackInfo.directPlay,
|
||||
"needsTranscoding:",
|
||||
playbackInfo.needsTranscoding,
|
||||
);
|
||||
mediaSourceId = playbackInfo.mediaSourceId;
|
||||
|
||||
// Prefer a completed download over streaming. Audio has done this
|
||||
@@ -336,7 +401,7 @@
|
||||
log.debug(
|
||||
source.isLocal
|
||||
? "loadAndPlay: Playing downloaded file from disk"
|
||||
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
||||
: `loadAndPlay: Using stream URL: ${streamUrl}`,
|
||||
);
|
||||
|
||||
// Set initial position for the video player to seek to after load.
|
||||
@@ -358,68 +423,105 @@
|
||||
// For audio, use MPV backend
|
||||
log.debug("loadAndPlay: Using MPV backend for audio");
|
||||
|
||||
// Check if we have a queue parameter (e.g., queue=parent:albumId)
|
||||
const queueParamValue = queueParam;
|
||||
if (queueParamValue?.startsWith("parent:")) {
|
||||
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
|
||||
log.debug("loadAndPlay: Loading queue from parent:", parentId);
|
||||
// Check if we have a queue parameter (e.g., queue=parent:albumId)
|
||||
const queueParamValue = queueParam;
|
||||
if (queueParamValue?.startsWith("parent:")) {
|
||||
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
|
||||
log.debug("loadAndPlay: Loading queue from parent:", parentId);
|
||||
|
||||
// Fetch all tracks from the parent (album/playlist)
|
||||
const result = await repo.getItems(parentId, {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
limit: 500,
|
||||
});
|
||||
const audioTracks = result.items.filter(t => t.kind === "track");
|
||||
// Fetch all tracks from the parent (album/playlist)
|
||||
const result = await repo.getItems(parentId, {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
limit: 500,
|
||||
});
|
||||
const audioTracks = result.items.filter((t) => t.kind === "track");
|
||||
|
||||
if (audioTracks.length > 0) {
|
||||
// Find the index of the current item in the tracks
|
||||
const startIndex = audioTracks.findIndex(t => t.id === id);
|
||||
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
|
||||
if (audioTracks.length > 0) {
|
||||
// Find the index of the current item in the tracks
|
||||
const startIndex = audioTracks.findIndex((t) => t.id === id);
|
||||
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
|
||||
|
||||
log.debug("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
|
||||
log.debug(
|
||||
"loadAndPlay: Building queue with",
|
||||
audioTracks.length,
|
||||
"tracks, startIndex:",
|
||||
actualStartIndex,
|
||||
);
|
||||
|
||||
// Build queue items with stream URLs
|
||||
// Add error handling and logging for each track
|
||||
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
|
||||
try {
|
||||
log.debug(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
|
||||
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
||||
if (!trackStreamUrl) {
|
||||
log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
|
||||
throw new Error(`Failed to get stream URL for ${t.name}`);
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.name,
|
||||
artist: t.artists?.join(", ") || null,
|
||||
album: t.albumName || null,
|
||||
duration: t.durationMs ? t.durationMs / 1000 : null,
|
||||
artworkUrl: t.imageId
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
|
||||
: null,
|
||||
mediaType: "audio",
|
||||
streamUrl: trackStreamUrl,
|
||||
jellyfinItemId: t.id,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
|
||||
throw e; // Re-throw to fail fast and show error to user
|
||||
}
|
||||
}));
|
||||
// Build queue items with stream URLs
|
||||
// Add error handling and logging for each track
|
||||
const queueItems = await Promise.all(
|
||||
audioTracks.map(async (t, idx) => {
|
||||
try {
|
||||
log.debug(
|
||||
`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`,
|
||||
);
|
||||
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
||||
if (!trackStreamUrl) {
|
||||
log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
|
||||
throw new Error(`Failed to get stream URL for ${t.name}`);
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.name,
|
||||
artist: t.artists?.join(", ") || null,
|
||||
album: t.albumName || null,
|
||||
duration: t.durationMs ? t.durationMs / 1000 : null,
|
||||
artworkUrl: t.imageId
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: t.imageId,
|
||||
})
|
||||
: null,
|
||||
mediaType: "audio",
|
||||
streamUrl: trackStreamUrl,
|
||||
jellyfinItemId: t.id,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
|
||||
throw e; // Re-throw to fail fast and show error to user
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Use player_play_queue to set up the backend queue
|
||||
await commands.playerPlayQueue({
|
||||
items: queueItems,
|
||||
startIndex: actualStartIndex,
|
||||
shuffle: shuffleParam,
|
||||
} as unknown as PlayQueueRequest);
|
||||
// Use player_play_queue to set up the backend queue
|
||||
await commands.playerPlayQueue({
|
||||
items: queueItems,
|
||||
startIndex: actualStartIndex,
|
||||
shuffle: shuffleParam,
|
||||
} as unknown as PlayQueueRequest);
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
log.debug("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
|
||||
// Queue will auto-update from Rust backend event
|
||||
log.debug(
|
||||
"loadAndPlay: Successfully set up queue with",
|
||||
audioTracks.length,
|
||||
"tracks",
|
||||
);
|
||||
} else {
|
||||
// Fallback to single item playback
|
||||
log.debug(
|
||||
"loadAndPlay: No audio tracks found in parent, falling back to single item",
|
||||
);
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
|
||||
await commands.playerPlayTracks(repositoryHandle, {
|
||||
trackIds: [item.id],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
log.debug("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
} else {
|
||||
// Fallback to single item playback
|
||||
log.debug("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||
// No queue parameter - single item playback
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
@@ -437,25 +539,6 @@
|
||||
// Queue will auto-update from Rust backend event
|
||||
log.debug("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
} else {
|
||||
// No queue parameter - single item playback
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
|
||||
await commands.playerPlayTracks(repositoryHandle, {
|
||||
trackIds: [item.id],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
log.debug("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
|
||||
// Seek to start position if provided
|
||||
if (startPosition) {
|
||||
@@ -468,11 +551,22 @@
|
||||
loading = false;
|
||||
|
||||
// Fetch next episode for video episodes (for skip button)
|
||||
nextEpisodeLog.debug("Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
|
||||
nextEpisodeLog.debug(
|
||||
"Post-load check: isVideo=",
|
||||
isVideo,
|
||||
"currentMedia=",
|
||||
currentMedia?.kind,
|
||||
currentMedia?.name,
|
||||
);
|
||||
if (isVideo && currentMedia) {
|
||||
fetchNextEpisode(currentMedia);
|
||||
} else {
|
||||
nextEpisodeLog.debug("Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
|
||||
nextEpisodeLog.debug(
|
||||
"Skipped fetchNextEpisode - isVideo:",
|
||||
isVideo,
|
||||
"currentMedia:",
|
||||
!!currentMedia,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("loadAndPlay error:", e);
|
||||
@@ -545,7 +639,10 @@
|
||||
*
|
||||
* TRACES: UR-004, UR-005, UR-021 | DR-181
|
||||
*/
|
||||
async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise<string> {
|
||||
async function handleVideoSeek(
|
||||
_positionSeconds: number,
|
||||
audioStreamIndex?: number,
|
||||
): Promise<string> {
|
||||
const repo = auth.getRepository();
|
||||
const id = itemId;
|
||||
if (!id) throw new Error("No item ID");
|
||||
@@ -604,7 +701,13 @@
|
||||
// and check for next episodes. HTML5 video plays independently of the Rust
|
||||
// backend queue, so the backend needs these to know what just finished.
|
||||
const mediaId = currentMedia?.id ?? null;
|
||||
autoPlayLog.debug("Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
|
||||
autoPlayLog.debug(
|
||||
"Video ended. currentMedia:",
|
||||
mediaId,
|
||||
currentMedia?.name,
|
||||
"itemId (URL):",
|
||||
itemId,
|
||||
);
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const repoHandle = repo.getHandle();
|
||||
@@ -616,7 +719,14 @@
|
||||
|
||||
async function fetchNextEpisode(media: MediaItem) {
|
||||
nextEpisode = null;
|
||||
nextEpisodeLog.debug("fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
|
||||
nextEpisodeLog.debug("fetchNextEpisode called:", {
|
||||
kind: media.kind,
|
||||
seriesId: media.seriesId,
|
||||
seasonId: media.seasonId,
|
||||
indexNumber: media.indexNumber,
|
||||
id: media.id,
|
||||
name: media.name,
|
||||
});
|
||||
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
|
||||
nextEpisodeLog.debug("Skipping - not an episode or missing seasonId/indexNumber");
|
||||
return;
|
||||
@@ -624,17 +734,37 @@
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
// Fetch all episodes in the season sorted by episode number
|
||||
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
|
||||
const episodes = result.items.filter(e => e.kind === "episode");
|
||||
nextEpisodeLog.debug("Season has", episodes.length, "episodes, current index:", media.indexNumber);
|
||||
const result = await repo.getItems(media.seasonId, {
|
||||
sortBy: "IndexNumber",
|
||||
sortOrder: "Ascending",
|
||||
limit: 500,
|
||||
});
|
||||
const episodes = result.items.filter((e) => e.kind === "episode");
|
||||
nextEpisodeLog.debug(
|
||||
"Season has",
|
||||
episodes.length,
|
||||
"episodes, current index:",
|
||||
media.indexNumber,
|
||||
);
|
||||
|
||||
// Find the episode after the current one by index number
|
||||
const currentIdx = episodes.findIndex(e => e.id === media.id);
|
||||
const currentIdx = episodes.findIndex((e) => e.id === media.id);
|
||||
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
|
||||
nextEpisode = episodes[currentIdx + 1];
|
||||
nextEpisodeLog.debug("Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
|
||||
nextEpisodeLog.debug(
|
||||
"Set nextEpisode:",
|
||||
nextEpisode.name,
|
||||
"index:",
|
||||
nextEpisode.indexNumber,
|
||||
);
|
||||
} else {
|
||||
nextEpisodeLog.debug("No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
|
||||
nextEpisodeLog.debug(
|
||||
"No next episode in season (current position:",
|
||||
currentIdx,
|
||||
"of",
|
||||
episodes.length,
|
||||
")",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
nextEpisodeLog.error("Failed to fetch next episode:", e);
|
||||
@@ -664,9 +794,9 @@
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -675,7 +805,9 @@
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 max-w-md mx-4 shadow-xl">
|
||||
<h2 class="text-xl font-semibold mb-4">Resume Playback?</h2>
|
||||
<p class="text-gray-300 mb-2">
|
||||
You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo ? 'video' : 'audio'}.
|
||||
You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo
|
||||
? "video"
|
||||
: "audio"}.
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm mb-6">
|
||||
Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning?
|
||||
@@ -700,11 +832,9 @@
|
||||
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
|
||||
<div class="text-center max-w-lg">
|
||||
<p class="text-red-400 mb-4 text-lg font-semibold">Playback Error</p>
|
||||
<pre class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg"
|
||||
>
|
||||
<pre
|
||||
class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
|
||||
<button onclick={handleClose} class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg">
|
||||
Back to Library
|
||||
</button>
|
||||
</div>
|
||||
@@ -713,7 +843,9 @@
|
||||
<!-- "pending" = video whose stream URL has not resolved yet. Showing the
|
||||
spinner keeps it out of the audio player. -->
|
||||
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if surface === "video" && streamUrl}
|
||||
<VideoPlayer
|
||||
|
||||
Reference in New Issue
Block a user