jellytau/src/routes/player/[id]/+page.svelte
Duncan Tourolle d01c2aab9f
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Migrate all IPC call sites to typed tauri-specta commands.*
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.

- Register repository_get_subtitle_url and repository_get_video_download_url
  in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
  ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
  - VideoDownloadButton passed an un-awaited Promise as the stream URL.
  - setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
  enforce what they checked.

svelte-check: 0 errors. vitest: green. cargo test --lib: green.
2026-06-21 08:47:04 +02:00

631 lines
25 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { queue, currentQueueItem } from "$lib/stores/queue";
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { playbackPosition, playbackDuration, 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 NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
import {
reportPlaybackStart,
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue"));
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
// Derive playback context from URL query params
const playbackContext = $derived.by(() => {
if (!queueParam) {
return { type: "single" as const, id: null };
}
if (queueParam.startsWith("parent:")) {
const parentId = queueParam.substring(7);
return { type: "container" as const, id: parentId };
}
return { type: "single" as const, id: null };
});
// Use player store for position/duration - updated by event system
const position = $derived($playbackPosition);
const duration = $derived($playbackDuration);
let isPlaying = $state(false);
let shuffle = $state(false);
let repeat = $state<"off" | "all" | "one">("off");
let hasNext = $state(false);
let hasPrevious = $state(false);
let currentMedia = $state<MediaItem | null>(null);
let streamUrl = $state<string | null>(null);
let mediaSourceId = $state<string | null>(null);
let isVideo = $state(false);
let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
let isOfflinePlayback = $state(false); // Whether playing from local file
let loading = $state(true);
let error = $state<string | null>(null);
let showResumeDialog = $state(false);
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
let nextEpisode = $state<MediaItem | null>(null); // Next episode for video skip button
let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadedItemId: string | null = 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;
if (id && id !== loadedItemId) {
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")");
loadAndPlay(id);
}
});
// Update currentMedia when queue item changes (for skip/next/previous)
// Only for audio content - video content uses direct loading and shouldn't be affected by audio queue
$effect(() => {
const queueItem = $currentQueueItem;
const currentIsVideo = currentMedia?.type === "Movie" || currentMedia?.type === "Episode";
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
currentMedia = queueItem;
}
});
async function loadAndPlay(id: string, startPosition?: number) {
loading = true;
error = null;
loadedItemId = id;
let retrievedProgressSeconds: number | null = null;
try {
console.log("loadAndPlay: Loading item", id);
// Load item details
const item = await library.loadItem(id);
console.log("loadAndPlay: Loaded item", item.name, "type:", item.type);
currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"];
if (collectionTypes.includes(item.type)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
goto(`/library/${id}`);
return;
}
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer)
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isVideo = item.type === "Movie" || item.type === "Episode";
isPlaying = true;
loading = false;
// Sync queue status
try {
const queueStatus = await commands.playerGetQueue();
hasNext = queueStatus.hasNext;
hasPrevious = queueStatus.hasPrevious;
} catch (e) {
// Ignore - queue status will update via polling
}
// Fetch next episode for video skip button
if (isVideo) {
fetchNextEpisode(item);
}
return;
}
// Determine if this is video content (Movie and Episode are video types)
isVideo = item.type === "Movie" || item.type === "Episode";
// When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state
if (isVideo) {
try {
await 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
const userId = auth.getUserId();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition);
if (!startPosition && userId) {
try {
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
const positionSeconds = progress.positionTicks / 10_000_000;
const totalSeconds = item.runTimeTicks / 10_000_000;
const progressPercent = (positionSeconds / totalSeconds) * 100;
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
// Store for later use regardless of whether dialog is shown
retrievedProgressSeconds = positionSeconds;
// Show resume dialog if watched > 30 seconds and < 90% complete
if (positionSeconds > 30 && progressPercent < 90) {
console.log("Resume check - SHOWING RESUME DIALOG");
savedProgress = { positionSeconds, progressPercent };
showResumeDialog = true;
loading = false;
return; // Wait for user decision
} else {
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
}
} else {
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks);
}
} catch (e) {
console.error("Failed to check saved progress:", e);
// Continue with normal playback
}
} else {
console.log("Resume check - Skipped. Reason:", !userId ? "No userId" : "Has startPosition");
}
// Check if this item is downloaded locally
const downloadsState = get(downloads);
const localDownload = Object.values(downloadsState.downloads).find(
(d: DownloadInfo) => d.itemId === id && d.status === "completed"
);
if (localDownload) {
// Use local file for playback
console.log("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
isOfflinePlayback = true;
// Get the storage path and construct full file path
const storagePath = await commands.storageGetPath();
const fullPath = `${storagePath}/${localDownload.filePath}`;
console.log("loadAndPlay: Full local path:", fullPath);
// Convert file path to asset URL that can be played in webview
const localUrl = convertFileSrc(fullPath);
console.log("loadAndPlay: Converted to asset URL:", localUrl);
if (isVideo) {
// Local video files don't need transcoding and support native seeking
streamUrl = localUrl;
videoNeedsTranscoding = false;
// Use explicit startPosition, or fall back to retrieved progress from database
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
videoInitialPosition = effectivePosition;
} else {
// Local audio playback via MPV backend
console.log("loadAndPlay: Using MPV backend for offline audio");
// 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();
console.log("loadAndPlay: Getting playback info");
const playbackInfo = await repo.getPlaybackInfo(id);
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
if (isVideo) {
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
console.log("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
mediaSourceId = playbackInfo.mediaSourceId;
videoNeedsTranscoding = playbackInfo.needsTranscoding;
// Use the stream URL from playback info (already transcoded if needed)
streamUrl = playbackInfo.streamUrl;
console.log("loadAndPlay: Using stream URL:", streamUrl);
// Set initial position for video player to seek to after load
// Use explicit startPosition, or fall back to retrieved progress from database
// For transcoded content, we need to request a new stream with StartTimeTicks
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
if (effectivePosition > 0) {
if (videoNeedsTranscoding) {
// For transcoded streams, get a new URL starting at the position
console.log("loadAndPlay: Getting transcoded stream starting at:", effectivePosition);
streamUrl = await repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, effectivePosition);
} else {
// For direct streams, we'll seek after load
videoInitialPosition = effectivePosition;
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
}
} else {
videoInitialPosition = 0;
}
} else {
// For audio, use MPV backend
console.log("loadAndPlay: Using MPV backend for audio");
// Check if we have a queue parameter (e.g., queue=parent:albumId)
const queueParamValue = queueParam;
if (queueParamValue?.startsWith("parent:")) {
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
console.log("loadAndPlay: Loading queue from parent:", parentId);
// Fetch all tracks from the parent (album/playlist)
const result = await repo.getItems(parentId, {
sortBy: "SortName",
sortOrder: "Ascending",
limit: 500,
});
const audioTracks = result.items.filter(t => t.type === "Audio");
if (audioTracks.length > 0) {
// Find the index of the current item in the tracks
const startIndex = audioTracks.findIndex(t => t.id === id);
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
console.log("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
// Build queue items with stream URLs
// Add error handling and logging for each track
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
try {
console.log(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
if (!trackStreamUrl) {
console.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
throw new Error(`Failed to get stream URL for ${t.name}`);
}
return {
id: t.id,
title: t.name,
artist: t.artists?.join(", ") || null,
album: t.albumName || null,
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null,
artworkUrl: t.primaryImageTag
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag })
: null,
mediaType: "audio",
streamUrl: trackStreamUrl,
jellyfinItemId: t.id,
};
} catch (e) {
console.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
throw e; // Re-throw to fail fast and show error to user
}
}));
// Use player_play_queue to set up the backend queue
await 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?.type, 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) {
loadAndPlay(id, 0);
}
}
function handleResumeFromSaved() {
showResumeDialog = false;
const position = savedProgress?.positionSeconds ?? 0;
savedProgress = null;
const id = itemId;
if (id) {
loadAndPlay(id, position);
}
}
async function updateStatus() {
try {
const status = await 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 = status.shuffle;
repeat = status.repeat;
// Update queue status
const queue = await commands.playerGetQueue();
hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious;
} catch (e) {
// Ignore polling errors
}
}
function handleClose() {
// Use browser history to go back to the previous page
// This ensures users return to where they came from (album, series, search, etc.)
if (window.history.length > 1) {
window.history.back();
} else {
// Fallback to library if no history (e.g., direct URL access)
goto("/library");
}
}
/**
* Handle video seeking by requesting a new stream URL starting at the given position.
* Transcoded streams don't support native seeking, so we restart from a new position.
*/
async function handleVideoSeek(positionSeconds: number, audioStreamIndex?: number): Promise<string> {
const repo = auth.getRepository();
const id = itemId;
if (!id) throw new Error("No item ID");
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, positionSeconds, audioStreamIndex);
}
// Playback reporting callbacks
function handleReportStart(positionSeconds: number) {
const id = itemId;
const context = playbackContext; // playbackContext is a derived value, not a function
if (id) {
reportPlaybackStart(id, positionSeconds, context.type, context.id);
}
}
function handleReportProgress(positionSeconds: number, isPaused: boolean) {
const id = itemId;
if (id) {
reportPlaybackProgress(id, positionSeconds, isPaused);
}
}
function handleReportStop(positionSeconds: number) {
const id = itemId;
if (id) {
reportPlaybackStopped(id, positionSeconds);
}
}
async function handleVideoEnded() {
// Call backend to handle autoplay decision (works on both Android and Linux)
// 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:", { type: media.type, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
if (media.type !== "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.type === "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) {
// Use replaceState so "close/back" returns to the library, not the previous episode
goto(`/player/${nextEpisode.id}`, { 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 loading}
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if error}
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
<div class="text-center max-w-lg">
<p class="text-red-400 mb-4 text-lg font-semibold">Playback Error</p>
<pre class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
<button
onclick={handleClose}
class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg"
>
Back to Library
</button>
</div>
</div>
{:else if isVideo && streamUrl}
<VideoPlayer
media={currentMedia}
{streamUrl}
mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding}
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}