TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
144 lines
4.4 KiB
Svelte
144 lines
4.4 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";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("WatchedToggleButton");
|
|
|
|
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;
|
|
log.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>
|