mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
906 lines
36 KiB
Svelte
906 lines
36 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-225
|
|
*/
|
|
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-225
|
|
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;
|
|
// Wait for session restore rather than failing on a race: this runs on
|
|
// mount, and at startup (or after a hot reload) the repository may be a
|
|
// few hundred milliseconds behind. Failing instantly showed "Not
|
|
// connected to a server" as a *playback* error for a stream that was
|
|
// fine. TRACES: UR-002, UR-004 | DR-013
|
|
const repo = await auth.waitForRepository();
|
|
|
|
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-225
|
|
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-225, DR-227, DR-228
|
|
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}
|