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.
141 lines
4.3 KiB
Svelte
141 lines
4.3 KiB
Svelte
<!--
|
|
Mark an episode, season or series watched — or unwatched again.
|
|
|
|
The backend already had both halves (`mark_played` / `clear_watch_history`,
|
|
both recursive over a container on the server) and the sync queue already
|
|
replayed the first; nothing in the UI had ever called them, so the only way to
|
|
mark something watched was to sit through it. This is that control.
|
|
|
|
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
|
|
this series", confirms, and needs the server — this is an everyday toggle: no
|
|
confirmation, and it works offline by queueing, in both directions.
|
|
|
|
TRACES: UR-073 | DR-158
|
|
-->
|
|
<script lang="ts">
|
|
import { syncService } from "$lib/services/syncService";
|
|
|
|
interface Props {
|
|
/** Episode, season or series id. */
|
|
itemId: string;
|
|
/** Current watched state, as the caller knows it. */
|
|
watched: boolean;
|
|
/** What is being marked, for the tooltip wording. */
|
|
scope: "episode" | "season" | "series";
|
|
size?: "sm" | "lg";
|
|
/** Show a text label beside the icon rather than icon-only. */
|
|
showLabel?: boolean;
|
|
/** Called after a successful toggle so the caller can reload. */
|
|
onChanged?: (watched: boolean) => void;
|
|
}
|
|
|
|
let {
|
|
itemId,
|
|
watched,
|
|
scope,
|
|
size = "lg",
|
|
showLabel = false,
|
|
onChanged,
|
|
}: Props = $props();
|
|
|
|
let busy = $state(false);
|
|
|
|
// Optimistic state: the caller's `watched` prop only catches up once it has
|
|
// reloaded from the repository, which on a season means a round trip. Without
|
|
// this the button visibly ignores the first tap.
|
|
let optimistic = $state<boolean | null>(null);
|
|
const isWatched = $derived(optimistic ?? watched);
|
|
|
|
// A new item in the same slot (scrolling a virtualised list, switching series)
|
|
// must drop the previous item's optimistic state or it shows the wrong tick.
|
|
$effect(() => {
|
|
itemId;
|
|
optimistic = null;
|
|
});
|
|
|
|
const subject = $derived(
|
|
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
|
);
|
|
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
|
const title = $derived(
|
|
isWatched
|
|
? `Mark this ${subject} unwatched`
|
|
: scope === "episode"
|
|
? "Mark this episode watched"
|
|
: `Mark every episode in this ${subject} watched`
|
|
);
|
|
|
|
async function handleClick() {
|
|
if (busy) return;
|
|
|
|
const next = !isWatched;
|
|
busy = true;
|
|
optimistic = next;
|
|
try {
|
|
if (next) {
|
|
await syncService.queueMarkPlayed(itemId);
|
|
} else {
|
|
await syncService.queueMarkUnplayed(itemId);
|
|
}
|
|
onChanged?.(next);
|
|
} catch (e) {
|
|
// Put the button back where it was — the change did not happen.
|
|
optimistic = null;
|
|
console.error("Failed to change watched state:", e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={handleClick}
|
|
disabled={busy}
|
|
{title}
|
|
aria-label={title}
|
|
aria-pressed={isWatched}
|
|
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
|
disabled:opacity-40 disabled:cursor-not-allowed
|
|
{isWatched
|
|
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
|
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
|
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
|
>
|
|
{#if busy}
|
|
<div
|
|
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
|
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
|
></div>
|
|
{:else if isWatched}
|
|
<!-- Filled check: this one is done. -->
|
|
<svg
|
|
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
|
|
6.4-6.4L18.4 8.8l-7.8 7.8z"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<!-- Outline check: available, not yet done. -->
|
|
<svg
|
|
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
|
|
</svg>
|
|
{/if}
|
|
{#if showLabel}
|
|
<span>{busy ? "Saving…" : label}</span>
|
|
{/if}
|
|
</button>
|