First working POC
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||
import { playbackPosition, playbackDuration } 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 NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
|
||||
import {
|
||||
reportPlaybackStart,
|
||||
reportPlaybackProgress,
|
||||
reportPlaybackStopped,
|
||||
} from "$lib/services/playbackReporting";
|
||||
import { cleanup as cleanupNextEpisode, handleEpisodeEnded } from "$lib/services/nextEpisodeService";
|
||||
|
||||
const itemId = $derived($page.params.id);
|
||||
const queueParam = $derived($page.url.searchParams.get("queue"));
|
||||
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
|
||||
|
||||
// Derive playback context from URL query params
|
||||
const playbackContext = $derived.by(() => {
|
||||
if (!queueParam) {
|
||||
return { type: "single" as const, id: null };
|
||||
}
|
||||
|
||||
if (queueParam.startsWith("parent:")) {
|
||||
const parentId = queueParam.substring(7);
|
||||
return { type: "container" as const, id: parentId };
|
||||
}
|
||||
|
||||
return { type: "single" as const, id: null };
|
||||
});
|
||||
|
||||
// Use player store for position/duration - updated by event system
|
||||
const position = $derived($playbackPosition);
|
||||
const duration = $derived($playbackDuration);
|
||||
let isPlaying = $state(false);
|
||||
let shuffle = $state(false);
|
||||
let repeat = $state<"off" | "all" | "one">("off");
|
||||
let hasNext = $state(false);
|
||||
let hasPrevious = $state(false);
|
||||
let currentMedia = $state<MediaItem | null>(null);
|
||||
let streamUrl = $state<string | null>(null);
|
||||
let mediaSourceId = $state<string | null>(null);
|
||||
let isVideo = $state(false);
|
||||
let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
|
||||
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
|
||||
let isOfflinePlayback = $state(false); // Whether playing from local file
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let showResumeDialog = $state(false);
|
||||
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let loadedItemId: string | null = null;
|
||||
|
||||
// Handle next episode navigation event from popup
|
||||
function handlePlayNextEpisode(event: Event) {
|
||||
const customEvent = event as CustomEvent<{ episode: MediaItem }>;
|
||||
const episode = customEvent.detail.episode;
|
||||
if (episode) {
|
||||
goto(`/player/${episode.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Start position polling (only for audio via MPV backend)
|
||||
pollInterval = setInterval(updateStatus, 1000);
|
||||
|
||||
// Listen for next episode navigation events
|
||||
window.addEventListener("playNextEpisode", handlePlayNextEpisode);
|
||||
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
window.removeEventListener("playNextEpisode", handlePlayNextEpisode);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupNextEpisode();
|
||||
});
|
||||
|
||||
// Load when itemId changes (handles both initial load and navigation)
|
||||
$effect(() => {
|
||||
const id = itemId;
|
||||
if (id && id !== loadedItemId) {
|
||||
loadAndPlay(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update currentMedia when queue item changes (for skip/next/previous)
|
||||
// Only for audio content - video content uses direct loading and shouldn't be affected by audio queue
|
||||
$effect(() => {
|
||||
const queueItem = $currentQueueItem;
|
||||
const currentIsVideo = currentMedia?.type === "Movie" || currentMedia?.type === "Episode";
|
||||
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
|
||||
currentMedia = queueItem;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAndPlay(id: string, startPosition?: number) {
|
||||
loading = true;
|
||||
error = null;
|
||||
loadedItemId = id;
|
||||
let retrievedProgressSeconds: number | null = null;
|
||||
|
||||
try {
|
||||
console.log("loadAndPlay: Loading item", id);
|
||||
// Load item details
|
||||
const item = await library.loadItem(id);
|
||||
console.log("loadAndPlay: Loaded item", item.name, "type:", item.type);
|
||||
currentMedia = item;
|
||||
|
||||
// Check if this is a non-playable collection type that should be viewed in library instead
|
||||
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"];
|
||||
if (collectionTypes.includes(item.type)) {
|
||||
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
|
||||
goto(`/library/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is video content (Movie and Episode are video types)
|
||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||
|
||||
// When switching to video, stop audio playback and clear the queue
|
||||
// This prevents audio from continuing in the background and clears stale state
|
||||
if (isVideo) {
|
||||
try {
|
||||
await invoke("player_stop");
|
||||
queue.clear();
|
||||
console.log("loadAndPlay: Stopped audio backend for video playback");
|
||||
} catch (e) {
|
||||
// Ignore - player may not have been playing
|
||||
}
|
||||
}
|
||||
|
||||
// Check for saved progress if no start position specified
|
||||
const userId = auth.getUserId();
|
||||
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition);
|
||||
|
||||
if (!startPosition && userId) {
|
||||
try {
|
||||
const progress = await invoke<{ positionTicks: number } | null>(
|
||||
"storage_get_playback_progress",
|
||||
{ userId, itemId: id }
|
||||
);
|
||||
console.log("Resume check - retrieved progress:", progress);
|
||||
|
||||
if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
|
||||
const positionSeconds = progress.positionTicks / 10_000_000;
|
||||
const totalSeconds = item.runTimeTicks / 10_000_000;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
|
||||
|
||||
// Store for later use regardless of whether dialog is shown
|
||||
retrievedProgressSeconds = positionSeconds;
|
||||
|
||||
// Show resume dialog if watched > 30 seconds and < 90% complete
|
||||
if (positionSeconds > 30 && progressPercent < 90) {
|
||||
console.log("Resume check - SHOWING RESUME DIALOG");
|
||||
savedProgress = { positionSeconds, progressPercent };
|
||||
showResumeDialog = true;
|
||||
loading = false;
|
||||
return; // Wait for user decision
|
||||
} else {
|
||||
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
|
||||
}
|
||||
} else {
|
||||
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check saved progress:", e);
|
||||
// Continue with normal playback
|
||||
}
|
||||
} else {
|
||||
console.log("Resume check - Skipped. Reason:", !userId ? "No userId" : "Has startPosition");
|
||||
}
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
if (localDownload) {
|
||||
// Use local file for playback
|
||||
console.log("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
|
||||
isOfflinePlayback = true;
|
||||
|
||||
// Get the storage path and construct full file path
|
||||
const storagePath = await invoke<string>("storage_get_path");
|
||||
const fullPath = `${storagePath}/${localDownload.filePath}`;
|
||||
console.log("loadAndPlay: Full local path:", fullPath);
|
||||
|
||||
// Convert file path to asset URL that can be played in webview
|
||||
const localUrl = convertFileSrc(fullPath);
|
||||
console.log("loadAndPlay: Converted to asset URL:", localUrl);
|
||||
|
||||
if (isVideo) {
|
||||
// Local video files don't need transcoding and support native seeking
|
||||
streamUrl = localUrl;
|
||||
videoNeedsTranscoding = false;
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||
videoInitialPosition = effectivePosition;
|
||||
} else {
|
||||
// Local audio playback via MPV backend
|
||||
console.log("loadAndPlay: Using MPV backend for offline audio");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: null, // Local file may not have artwork
|
||||
mediaType: "audio",
|
||||
streamUrl: localUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
if (startPosition) {
|
||||
await invoke("player_seek", { position: startPosition });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Online playback - get playback info from server
|
||||
isOfflinePlayback = false;
|
||||
const repo = auth.getRepository();
|
||||
console.log("loadAndPlay: Getting playback info");
|
||||
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||
|
||||
if (isVideo) {
|
||||
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||
console.log("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
|
||||
mediaSourceId = playbackInfo.mediaSourceId;
|
||||
videoNeedsTranscoding = playbackInfo.needsTranscoding;
|
||||
|
||||
// Use the stream URL from playback info (already transcoded if needed)
|
||||
streamUrl = playbackInfo.streamUrl;
|
||||
console.log("loadAndPlay: Using stream URL:", streamUrl);
|
||||
|
||||
// Set initial position for video player to seek to after load
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
// For transcoded content, we need to request a new stream with StartTimeTicks
|
||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||
if (effectivePosition > 0) {
|
||||
if (videoNeedsTranscoding) {
|
||||
// For transcoded streams, get a new URL starting at the position
|
||||
console.log("loadAndPlay: Getting transcoded stream starting at:", effectivePosition);
|
||||
streamUrl = await repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, effectivePosition);
|
||||
} else {
|
||||
// For direct streams, we'll seek after load
|
||||
videoInitialPosition = effectivePosition;
|
||||
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
||||
}
|
||||
} else {
|
||||
videoInitialPosition = 0;
|
||||
}
|
||||
} else {
|
||||
// For audio, use MPV backend
|
||||
console.log("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
|
||||
console.log("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.type === "Audio");
|
||||
|
||||
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;
|
||||
|
||||
console.log("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 {
|
||||
console.log(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
|
||||
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
||||
if (!trackStreamUrl) {
|
||||
console.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.runTimeTicks ? t.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: t.primaryImageTag
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag })
|
||||
: null,
|
||||
mediaType: "audio",
|
||||
streamUrl: trackStreamUrl,
|
||||
jellyfinItemId: t.id,
|
||||
};
|
||||
} catch (e) {
|
||||
console.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 invoke("player_play_queue", {
|
||||
request: {
|
||||
items: queueItems,
|
||||
startIndex: actualStartIndex,
|
||||
shuffle: shuffleParam,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
|
||||
} else {
|
||||
// Fallback to single item playback
|
||||
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
} else {
|
||||
// No queue parameter - single item playback
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
|
||||
// Seek to start position if provided
|
||||
if (startPosition) {
|
||||
await invoke("player_seek", { position: startPosition });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.error("loadAndPlay error:", e);
|
||||
// Show detailed error including the full error object
|
||||
if (e instanceof Error) {
|
||||
error = `${e.name}: ${e.message}`;
|
||||
} else {
|
||||
error = `Unknown error: ${JSON.stringify(e)}`;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResumeFromBeginning() {
|
||||
showResumeDialog = false;
|
||||
savedProgress = null;
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
loadAndPlay(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResumeFromSaved() {
|
||||
showResumeDialog = false;
|
||||
const position = savedProgress?.positionSeconds ?? 0;
|
||||
savedProgress = null;
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
loadAndPlay(id, position);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const status = await invoke<{
|
||||
state: { kind: string; position?: number; duration?: number };
|
||||
shuffle: boolean;
|
||||
repeat: string;
|
||||
}>("player_get_status");
|
||||
|
||||
if (status.state.kind === "playing" || status.state.kind === "paused") {
|
||||
isPlaying = status.state.kind === "playing";
|
||||
// Note: position/duration are now derived from player store (updated by events)
|
||||
}
|
||||
shuffle = status.shuffle;
|
||||
repeat = status.repeat as "off" | "all" | "one";
|
||||
|
||||
// Update queue status
|
||||
const queue = await invoke<{
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}>("player_get_queue");
|
||||
hasNext = queue.hasNext;
|
||||
hasPrevious = queue.hasPrevious;
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// Use browser history to go back to the previous page
|
||||
// This ensures users return to where they came from (album, series, search, etc.)
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Fallback to library if no history (e.g., direct URL access)
|
||||
goto("/library");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle video seeking by requesting a new stream URL starting at the given position.
|
||||
* Transcoded streams don't support native seeking, so we restart from a new position.
|
||||
*/
|
||||
async function handleVideoSeek(positionSeconds: number, audioStreamIndex?: number): Promise<string> {
|
||||
const repo = auth.getRepository();
|
||||
const id = itemId;
|
||||
if (!id) throw new Error("No item ID");
|
||||
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, positionSeconds, audioStreamIndex);
|
||||
}
|
||||
|
||||
// Playback reporting callbacks
|
||||
function handleReportStart(positionSeconds: number) {
|
||||
const id = itemId;
|
||||
const context = playbackContext; // playbackContext is a derived value, not a function
|
||||
if (id) {
|
||||
reportPlaybackStart(id, positionSeconds, context.type, context.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReportProgress(positionSeconds: number, isPaused: boolean) {
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
reportPlaybackProgress(id, positionSeconds, isPaused);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReportStop(positionSeconds: number) {
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
reportPlaybackStopped(id, positionSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideoEnded() {
|
||||
// Call backend to handle autoplay decision (works on both Android and Linux)
|
||||
try {
|
||||
await invoke("player_on_playback_ended");
|
||||
} catch (e) {
|
||||
console.error("[VideoPlayer] Failed to handle playback ended:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showResumeDialog && savedProgress}
|
||||
<div class="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<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'}.
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm mb-6">
|
||||
Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning?
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleResumeFromBeginning}
|
||||
class="flex-1 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Start from Beginning
|
||||
</button>
|
||||
<button
|
||||
onclick={handleResumeFromSaved}
|
||||
class="flex-1 px-4 py-3 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)] rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<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>
|
||||
{:else if error}
|
||||
<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"
|
||||
>
|
||||
Back to Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isVideo && streamUrl}
|
||||
<VideoPlayer
|
||||
media={currentMedia}
|
||||
{streamUrl}
|
||||
mediaSourceId={mediaSourceId}
|
||||
initialPosition={videoInitialPosition}
|
||||
needsTranscoding={videoNeedsTranscoding}
|
||||
onClose={handleClose}
|
||||
onSeek={handleVideoSeek}
|
||||
onReportStart={handleReportStart}
|
||||
onReportProgress={handleReportProgress}
|
||||
onReportStop={handleReportStop}
|
||||
onEnded={handleVideoEnded}
|
||||
/>
|
||||
<NextEpisodePopup />
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
media={currentMedia}
|
||||
{isPlaying}
|
||||
{position}
|
||||
{duration}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
{hasNext}
|
||||
{hasPrevious}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user