Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
901 lines
35 KiB
Svelte
901 lines
35 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 } from "$lib/player/localSource";
|
|
import type { PlayQueueRequest, StreamSelection } 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";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("PlayerPage");
|
|
const nextEpisodeLog = createLogger("NextEpisode");
|
|
const autoPlayLog = createLogger("AutoPlay");
|
|
|
|
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);
|
|
/**
|
|
* What to play, as the backend decided it. Null while still resolving.
|
|
*
|
|
* Replaces a bare URL string: the transport travels with it, so neither this
|
|
* page nor VideoPlayer has to work out whether the URL is a playlist.
|
|
*
|
|
* TRACES: UR-079 | DR-224
|
|
*/
|
|
let selection = $state<StreamSelection | 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: selection?.url ?? null }));
|
|
|
|
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) {
|
|
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);
|
|
}
|
|
});
|
|
|
|
// 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 {
|
|
log.debug("loadAndPlay: Loading item", id);
|
|
// Load item details
|
|
const item = await library.loadItem(id);
|
|
log.debug("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)) {
|
|
log.debug("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,
|
|
})
|
|
) {
|
|
log.debug("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();
|
|
log.debug("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();
|
|
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) {
|
|
try {
|
|
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
|
log.debug("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;
|
|
|
|
log.debug(
|
|
"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) {
|
|
log.debug("Resume check - SHOWING RESUME DIALOG");
|
|
savedProgress = { positionSeconds, progressPercent };
|
|
showResumeDialog = true;
|
|
loading = false;
|
|
return; // Wait for user decision
|
|
} else {
|
|
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,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
log.error("Failed to check saved progress:", e);
|
|
// Continue with normal playback
|
|
}
|
|
} else {
|
|
log.debug("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
|
|
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
|
|
// 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);
|
|
log.debug("loadAndPlay: Full local path:", fullPath);
|
|
|
|
if (isVideo) {
|
|
// Served 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) and states the
|
|
// transport with it.
|
|
//
|
|
// A downloaded file is a direct play over a local transport, and Rust
|
|
// says so rather than this page assuming it.
|
|
// TRACES: UR-071 | DR-137, DR-224
|
|
selection = await commands.mediaLocalSelection(fullPath);
|
|
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
|
|
log.debug("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.
|
|
log.debug("loadAndPlay: Opening live stream for channel:", id);
|
|
const liveInfo = await repo.openLiveStream(id);
|
|
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
|
mediaSourceId = liveInfo.mediaSourceId;
|
|
selection = {
|
|
url: liveInfo.streamUrl,
|
|
// Rust's verdict, not a guess from the URL.
|
|
transport: liveInfo.transport,
|
|
playbackKind: { type: "transcode" },
|
|
rendition: null,
|
|
// A live channel has no ladder to offer: there is no source file to
|
|
// measure and no rendition to re-negotiate against.
|
|
available: [],
|
|
mediaSourceId: liveInfo.mediaSourceId,
|
|
playSessionId: liveInfo.playSessionId,
|
|
needsTranscoding: true,
|
|
};
|
|
videoNeedsTranscoding = true;
|
|
videoInitialPosition = 0;
|
|
isPlaying = true;
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
if (isVideo) {
|
|
// 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.
|
|
//
|
|
// Checked *first* so the streaming path below negotiates exactly once:
|
|
// asking for a `PlaybackInfo` and then a stream selection meant two
|
|
// negotiations per load, and each one claims a transcode identity and
|
|
// retires the previous — so the server started a job only to be told
|
|
// to stop it a moment later. Observed in the log as a pair of
|
|
// `[StreamSelection]` lines for one play.
|
|
//
|
|
// TRACES: UR-071 | DR-123, DR-137, DR-224
|
|
const localPath = await commands.playerLocalMediaPath(id);
|
|
if (localPath) {
|
|
// A downloaded file is a direct play over a local transport, served
|
|
// by the loopback media server rather than the asset protocol
|
|
// (DR-137). Its media-source id still comes from the server, since
|
|
// that is what subtitle URLs are keyed by.
|
|
selection = await commands.mediaLocalSelection(localPath);
|
|
videoNeedsTranscoding = false;
|
|
mediaSourceId = (await repo.getPlaybackInfo(id)).mediaSourceId;
|
|
log.debug("loadAndPlay: Playing downloaded file from disk");
|
|
} else {
|
|
// Rust negotiates direct play vs direct stream vs transcode against
|
|
// the device profile and the ceiling in force, and returns the
|
|
// transport and the media-source id with it. This page no longer
|
|
// decides — or separately asks for — any of that.
|
|
// TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227
|
|
selection = await repo.getStreamSelection(id, null, null);
|
|
mediaSourceId = selection.mediaSourceId;
|
|
// Rust's own verdict — "which kinds count as transcoding" is a
|
|
// domain rule, and a direct *stream* is a remux that does not.
|
|
videoNeedsTranscoding = selection.needsTranscoding;
|
|
log.debug(
|
|
`loadAndPlay: ${selection.playbackKind.type} over ${selection.transport.type}`,
|
|
);
|
|
}
|
|
|
|
// 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) {
|
|
log.debug("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
|
}
|
|
} else {
|
|
// 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);
|
|
|
|
// 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;
|
|
|
|
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
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 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",
|
|
);
|
|
} 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 {
|
|
// 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) {
|
|
await commands.playerSeek(startPosition);
|
|
}
|
|
}
|
|
}
|
|
|
|
isPlaying = true;
|
|
loading = false;
|
|
|
|
// Fetch next episode for video episodes (for skip button)
|
|
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,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
log.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);
|
|
}
|
|
// The element's state is mirrored into Rust by VideoPlayer, which is the
|
|
// only place that knows whether a webview element is rendering at all.
|
|
// Doing it here mirrored unconditionally, so on the native path it told Rust
|
|
// a `<video>` was playing when none existed and transport was then aimed at
|
|
// it — see mirrorElementStateToRust in VideoPlayer.svelte (DR-195).
|
|
}
|
|
|
|
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
|
|
const id = reportId ?? itemId;
|
|
if (id) {
|
|
reportPlaybackProgress(id, positionSeconds, isPaused);
|
|
}
|
|
// Element state is mirrored by VideoPlayer (DR-195) — see handleReportStart.
|
|
}
|
|
|
|
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;
|
|
autoPlayLog.debug(
|
|
"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) {
|
|
autoPlayLog.error("Failed to handle playback ended:", e);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
|
|
nextEpisodeLog.debug("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");
|
|
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);
|
|
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
|
|
nextEpisode = episodes[currentIdx + 1];
|
|
nextEpisodeLog.debug(
|
|
"Set nextEpisode:",
|
|
nextEpisode.name,
|
|
"index:",
|
|
nextEpisode.indexNumber,
|
|
);
|
|
} else {
|
|
nextEpisodeLog.debug(
|
|
"No next episode in season (current position:",
|
|
currentIdx,
|
|
"of",
|
|
episodes.length,
|
|
")",
|
|
);
|
|
}
|
|
} catch (e) {
|
|
nextEpisodeLog.error("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" && selection}
|
|
<VideoPlayer
|
|
media={currentMedia}
|
|
{selection}
|
|
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}
|