Files
jellytau/src/routes/player/[id]/+page.svelte
T
dtourolle c0c6c5023e
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00

746 lines
32 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource";
import type { PlayQueueRequest } from "$lib/api/bindings";
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 { downloads, type DownloadInfo } from "$lib/stores/downloads";
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 NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
import {
reportPlaybackStart,
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue"));
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
// When advancing to a next episode we always start from the beginning,
// even if the episode was previously started or watched.
const restartParam = $derived($page.url.searchParams.get("restart") === "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);
// Shuffle/repeat/next/previous are event-driven via the queue store (instant
// update on queue_changed), not polled.
const shuffle = $derived($isShuffle);
const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
let currentMedia = $state<MediaItem | null>(null);
let streamUrl = $state<string | null>(null);
let mediaSourceId = $state<string | null>(null);
let isVideo = $state(false);
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
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 nextEpisode = $state<MediaItem | null>(null); // Next episode for video skip button
let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadedItemId: string | null = null;
// Which player component to render. Video without a stream URL is "pending"
// (still resolving), never audio — see playerSurface.ts.
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
onMount(() => {
// Start position polling (only for audio via MPV backend)
pollInterval = setInterval(updateStatus, 1000);
return () => {
if (pollInterval) clearInterval(pollInterval);
};
});
onDestroy(() => {
cleanupNextEpisode();
});
// Load when itemId changes (handles both initial load and navigation)
$effect(() => {
const id = itemId;
const restart = restartParam;
if (id && id !== loadedItemId) {
console.log("[AutoPlay] $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);
}
});
// 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?.kind === "movie" || currentMedia?.kind === "episode";
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
currentMedia = queueItem;
}
});
// A playable channel leaf (ChannelFolderItem) has no dedicated item type, so
// 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)
);
}
async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
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, "kind:", item.kind);
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"];
if (item.kind && collectionKinds.includes(item.kind)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.kind);
goto(`/library/${id}`);
return;
}
// 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);
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer).
// Audio only, and forceRestart bypasses it so advancing to the next
// episode always restarts from the beginning — see playerSurface.ts for
// why video must never take this shortcut.
const alreadyPlayingMedia = get(storeCurrentMedia);
if (
shouldReuseActivePlayback({
requestedId: id,
activeMediaId: alreadyPlayingMedia?.id,
isVideo,
startPosition,
forceRestart,
})
) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
return;
}
// 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 commands.playerStop();
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.
// 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();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
// Live streams have no fixed position - never resume.
if (!startPosition && !forceRestart && userId && !isLive) {
try {
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionMs > 0 && item.durationMs) {
const positionSeconds = progress.positionMs / 1000;
const totalSeconds = item.durationMs / 1000;
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?.positionMs, "Has runtime?", !!item.durationMs);
}
} 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 resolve the file's location. A completed
// row already holds an absolute path (the worker rewrites it on
// completion), so it must not be rooted again — see downloadedFilePath.
// TRACES: UR-071 | DR-133
const storagePath = await commands.storageGetPath();
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
console.log("loadAndPlay: Full local path:", fullPath);
// Serve the file over the loopback media server rather than the asset
// protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token).
// TRACES: UR-071 | DR-137
const localUrl = await commands.mediaLocalUrl(fullPath);
console.log("loadAndPlay: Local media URL resolved");
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");
// 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: "",
},
});
if (startPosition) {
await commands.playerSeek(startPosition);
}
}
} else {
// Online playback - get playback info from server
isOfflinePlayback = false;
const repo = auth.getRepository();
if (isLive) {
// Live TV channels must be "opened" before streaming; the server returns
// a ready-to-play HLS transcoding URL. No resume, no seek, no progress.
console.log("loadAndPlay: Opening live stream for channel:", id);
const liveInfo = await repo.openLiveStream(id);
console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
mediaSourceId = liveInfo.mediaSourceId;
streamUrl = liveInfo.streamUrl;
videoNeedsTranscoding = true;
videoInitialPosition = 0;
isPlaying = true;
loading = false;
return;
}
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;
// Prefer a completed download over streaming. Audio has done this
// since the queue is built; video previously always streamed, so a
// downloaded film re-spent bandwidth already spent and would not play
// at all offline. Rust returns null when nothing is downloaded or the
// file has gone, so this falls back to the server on its own.
// TRACES: UR-071 | DR-123
// A downloaded file is served over the loopback media server, not the
// asset protocol — see DR-137. The URL is minted up front because
// resolveVideoSource stays pure/synchronous.
// TRACES: UR-071 | DR-123, DR-137
const localPath = await commands.playerLocalMediaPath(id);
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null;
const source = resolveVideoSource({
localPath,
remoteUrl: playbackInfo.streamUrl,
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
toAssetUrl: () => localUrl ?? "",
});
streamUrl = source.url;
videoNeedsTranscoding = source.needsTranscoding;
console.log(
source.isLocal
? "loadAndPlay: Playing downloaded file from disk"
: `loadAndPlay: Using stream URL: ${streamUrl}`
);
// Set initial position for the video player to seek to after load.
// Use explicit startPosition, or fall back to retrieved progress.
//
// Transcoded streams resume the same way direct ones do — by seeking
// after load. Asking the server for a stream that *starts* at the
// position is what DR-181 removed: on an HLS playlist that position is
// copied onto every segment URI and the server then rejects each one
// with 400, so a resumed episode played nothing at all while the same
// episode from the beginning was fine.
// TRACES: UR-004, UR-019 | DR-181
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
if (videoInitialPosition > 0) {
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
}
} 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.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;
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.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) {
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 commands.playerPlayQueue({
items: queueItems,
startIndex: actualStartIndex,
shuffle: shuffleParam,
} as unknown as PlayQueueRequest);
// 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");
// 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
console.log("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
console.log("loadAndPlay: Set queue with single item:", item.name);
}
// Seek to start position if provided
if (startPosition) {
await commands.playerSeek(startPosition);
}
}
}
isPlaying = true;
loading = false;
// Fetch next episode for video episodes (for skip button)
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
if (isVideo && currentMedia) {
fetchNextEpisode(currentMedia);
} else {
console.log("[NextEpisode] Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
}
} 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) {
// forceRestart bypasses the resume-progress check; without it, passing a
// start position of 0 is treated as "no position" (`!startPosition`), which
// re-runs the resume check and re-shows this very dialog in a loop.
loadAndPlay(id, 0, true);
}
}
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 commands.playerGetStatus();
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/repeat/hasNext/hasPrevious come from the queue store (event-driven).
} 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");
}
}
/**
* Rebuild the stream for a transcoded seek or an audio-track switch.
*
* The returned URL starts at the beginning of the item, not at
* `positionSeconds`: a start position on an HLS playlist is copied onto every
* segment URI and rejected with 400 (DR-181). The caller seeks the reloaded
* element to the position — `positionSeconds` is kept in the signature because
* VideoPlayer's seek contract passes it, and the audio-track switch needs the
* same rebuild.
*
* TRACES: UR-004, UR-005, UR-021 | DR-181
*/
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, audioStreamIndex);
}
// Playback reporting callbacks
//
// These receive the reporting item id from the VideoPlayer (its own media.id),
// NOT the live URL param (itemId). During autoplay the URL flips to the next
// episode before the outgoing VideoPlayer's onDestroy fires its final
// reportStop. Keying off itemId would stamp the old episode's near-end
// position onto the new episode, making it resume at ~99% (or pop the resume
// dialog). The VideoPlayer always knows which media it actually played.
function handleReportStart(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
const context = playbackContext; // playbackContext is a derived value, not a function
if (id) {
reportPlaybackStart(id, positionSeconds, context.type, context.id);
}
// Mirror HTML5 <video> state into the Rust PlayerController so it is the
// single source of truth for video playback (see html5Adapter.ts). The
// element lives in the webview and Rust cannot observe it directly.
html5Adapter.reportState("playing", id ?? null);
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
}
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
reportPlaybackProgress(id, positionSeconds, isPaused);
}
// Feed the Rust controller the current position and play/pause state.
html5Adapter.reportState(isPaused ? "paused" : "playing", id ?? null);
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
}
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
// A skipped episode was already recorded as fully watched. Its unmount stop
// report arrives after the skip navigation carrying the mid-episode
// position; letting it through would undo that and restore the partial
// progress bar.
if (id && !shouldSuppressStopReport(id)) {
reportPlaybackStopped(id, positionSeconds);
}
// Intentionally do NOT emit a "stopped" player state here. This runs on both
// natural end-of-video (an autoplay handoff the backend's on_video_playback_ended
// owns) and on player close/unmount (where player_stop already drives the
// backend state). Emitting StateChanged{stopped} on natural end flips the
// player/mode to idle mid-handoff and suppresses next-episode auto-advance —
// the "sleep timer pauses at the end of an episode instead of continuing" bug.
}
async function handleVideoEnded() {
// Call backend to handle autoplay decision (works on both Android and Linux)
// Pass the item ID and repository handle so the backend can look up the item
// 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;
console.log("[AutoPlay] Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
try {
const repo = auth.getRepository();
const repoHandle = repo.getHandle();
await commands.playerOnPlaybackEnded(mediaId, repoHandle);
} catch (e) {
console.error("[AutoPlay] Failed to handle playback ended:", e);
}
}
async function fetchNextEpisode(media: MediaItem) {
nextEpisode = null;
console.log("[NextEpisode] 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) {
console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber");
return;
}
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");
console.log("[NextEpisode] 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);
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
nextEpisode = episodes[currentIdx + 1];
console.log("[NextEpisode] Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
} else {
console.log("[NextEpisode] No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
}
} catch (e) {
console.error("[NextEpisode] Failed to fetch next episode:", e);
}
}
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
// fully watched rather than leaving a mid-episode resume point behind. This
// also arms suppression of the VideoPlayer's unmount stop report, which
// would otherwise fire after navigation and overwrite the 100% progress
// with the partial position (see skipReporting.ts).
const skippedId = currentMedia?.id ?? itemId ?? null;
void reportSkippedEpisode(skippedId);
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
goto(`/player/${nextEpisode.id}?restart=true`, { replaceState: true });
}
}
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 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 loading || surface === "pending"}
<!-- "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>
{:else if surface === "video" && streamUrl}
<VideoPlayer
media={currentMedia}
{streamUrl}
mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding}
{isLive}
onClose={handleClose}
onSeek={handleVideoSeek}
onReportStart={handleReportStart}
onReportProgress={handleReportProgress}
onReportStop={handleReportStop}
onEnded={handleVideoEnded}
hasNext={nextEpisode !== null}
onNext={handleSkipToNextEpisode}
/>
<NextEpisodePopup />
{:else}
<AudioPlayer
media={currentMedia}
{isPlaying}
{position}
{duration}
{shuffle}
{repeat}
{hasNext}
{hasPrevious}
onClose={handleClose}
/>
{/if}