Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
215 lines
7.3 KiB
Svelte
215 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { formatDuration } from "$lib/utils/duration";
|
|
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
|
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
episode: MediaItem;
|
|
focused?: boolean;
|
|
/**
|
|
* This is the episode the viewer is up to. Marked and scrolled to when the
|
|
* series page opens, so a viewer four seasons deep lands on their place
|
|
* instead of the top of season 1. TRACES: UR-062 | DR-102
|
|
*/
|
|
current?: boolean;
|
|
onclick?: () => void;
|
|
/** Fired when the watched toggle changes, so the series page can reload. */
|
|
onWatchedChanged?: () => void;
|
|
}
|
|
|
|
let {
|
|
episode,
|
|
focused = false,
|
|
current = false,
|
|
onclick,
|
|
onWatchedChanged,
|
|
}: Props = $props();
|
|
|
|
let buttonRef: HTMLButtonElement | null = null;
|
|
|
|
onMount(() => {
|
|
if ((focused || current) && buttonRef) {
|
|
// Scroll into view with some offset from top
|
|
setTimeout(() => {
|
|
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
// Check if this episode is downloaded
|
|
const downloadInfo = $derived(
|
|
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
|
|
);
|
|
|
|
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
|
const isDownloading = $derived(
|
|
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
|
|
);
|
|
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
|
|
|
const progress = $derived(() => {
|
|
if (!episode.userData || !episode.durationMs) {
|
|
return 0;
|
|
}
|
|
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
|
|
});
|
|
|
|
const duration = $derived(formatDuration(episode.durationMs));
|
|
const episodeNumber = $derived(episode.indexNumber || 0);
|
|
</script>
|
|
|
|
<button
|
|
bind:this={buttonRef}
|
|
type="button"
|
|
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
|
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
|
: current
|
|
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
|
: ''}"
|
|
{onclick}
|
|
>
|
|
<!-- Thumbnail -->
|
|
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
|
<CachedImage
|
|
itemId={episode.id}
|
|
imageType="Primary"
|
|
tag={episode.imageId}
|
|
maxWidth={320}
|
|
alt={episode.name}
|
|
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
|
/>
|
|
|
|
<!-- Hover overlay with play icon -->
|
|
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
|
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
|
|
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress bar -->
|
|
{#if progress() > 0}
|
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
|
<div
|
|
class="h-full bg-[var(--color-jellyfin)]"
|
|
style="width: {progress()}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Download indicator -->
|
|
{#if isDownloaded || isDownloading}
|
|
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
|
{#if isDownloaded}
|
|
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
|
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
</div>
|
|
{:else if isDownloading}
|
|
<div class="w-5 h-5 relative">
|
|
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="rgba(0,0,0,0.6)"
|
|
stroke="rgba(255,255,255,0.3)"
|
|
stroke-width="2"
|
|
/>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
stroke-width="2"
|
|
stroke-dasharray={2 * Math.PI * 10}
|
|
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
|
|
stroke-linecap="round"
|
|
class="transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Episode info -->
|
|
<div class="flex-1 min-w-0 py-1">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0 flex-1">
|
|
<!-- Episode number and title -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
|
|
{episodeNumber}.
|
|
</span>
|
|
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
|
{truncateMiddle(episode.name, 56)}
|
|
</h3>
|
|
{#if current}
|
|
<span
|
|
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
|
>
|
|
Up next
|
|
</span>
|
|
{/if}
|
|
<!-- Played indicator -->
|
|
{#if episode.userData?.isPlayed}
|
|
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Overview -->
|
|
{#if episode.overview}
|
|
<p class="text-gray-400 text-sm mt-1 line-clamp-2">
|
|
{episode.overview}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Duration and Download -->
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
{#if duration}
|
|
<span class="text-gray-500 text-sm">
|
|
{duration}
|
|
</span>
|
|
{/if}
|
|
<!-- Watched toggle - stop propagation to prevent episode play -->
|
|
<div onclick={(e) => e.stopPropagation()} role="none">
|
|
<WatchedToggleButton
|
|
itemId={episode.id}
|
|
watched={episode.userData?.isPlayed ?? false}
|
|
scope="episode"
|
|
size="sm"
|
|
onChanged={onWatchedChanged}
|
|
/>
|
|
</div>
|
|
<!-- Download button - stop propagation to prevent episode play -->
|
|
<div onclick={(e) => e.stopPropagation()} role="none">
|
|
<VideoDownloadButton
|
|
itemId={episode.id}
|
|
itemName={episode.name}
|
|
seriesName={episode.seriesName ?? undefined}
|
|
seasonName={episode.seasonName ?? undefined}
|
|
episodeNumber={episode.indexNumber ?? undefined}
|
|
seasonNumber={episode.parentIndexNumber ?? undefined}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|