perf(series): the episode list no longer waits on the server
Opening Frasier on a Fairphone took ~5 s to render the episode list although every episode was cached. Three causes: - resolve_series_view waited for Next Up and resume before returning the episodes, and Next Up was server-first. The episode list now returns as soon as the episodes are in (with_hints); hints that have answered are used, late ones dropped, and the picker falls back to local watch state. Next Up is cache-first like every other query. - The page loaded itself six times per open: onMount plus a mount-time $effect, the reachability effect's first run posing as a reconnect, and a double mount. All triggers now share one coalesced load per item (createCoalescedLoader); refresh triggers get one re-run after it. - The root layout rendered the route in two branches that each rendered children; the page store deciding between them updates a flush late, so navigating Search -> library page mounted the page twice. One element now renders the route and only its classes change. On the device: one load per open, seasons from cache in 14 ms, episodes and the Resume button up in under a second (was ~5 s).
This commit is contained in:
@@ -666,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
|
||||
restructure the shell, preserve the scroll containment — reintroducing padding
|
||||
math reintroduces the bug.
|
||||
|
||||
**The route renders in exactly one element.** `+layout.svelte` switches the
|
||||
wrapper's *classes* between the shell scroller and the plain clipped box that
|
||||
layout-owning routes (library, settings, player) get — it must not switch
|
||||
between two branches that each render `children`. The page store that decides
|
||||
the mode can update a flush after the new route renders, so two branches
|
||||
mounted a page under one and then remounted it under the other: every
|
||||
navigation between the two kinds of route loaded the page twice (DR-295).
|
||||
|
||||
### AccountMenu
|
||||
|
||||
One component for both breakpoints, anchored to the username/avatar (a real
|
||||
@@ -719,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
|
||||
A season is not a destination: `/library/<seasonId>` redirects to its series
|
||||
(DR-103). Video library routes collapse to one per library (DR-105).
|
||||
|
||||
**The episode list never waits for Next Up or resume.** `resolve_series_view`
|
||||
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
|
||||
Next Up and resume are used if they have answered by then and dropped if not,
|
||||
and `pick_current_episode` falls back to the episodes' own watch state. They
|
||||
only refine which episode is current, and waiting for them held the list for
|
||||
the server's 2–3 s although every episode was cached. Next Up is cache-first
|
||||
like every other query (03-data-flow).
|
||||
|
||||
**One load per item, however many triggers.** The detail page loads through
|
||||
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
|
||||
already loading share that load, and callers that know the data changed
|
||||
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
|
||||
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
|
||||
effect's first run and the double mount above used to each start a full load —
|
||||
six per open, about seventy requests in flight.
|
||||
|
||||
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
||||
from the component because it had three distinct bugs that markup made
|
||||
untestable: the strip collapsing to just the current episode while real siblings
|
||||
|
||||
@@ -1000,22 +1000,32 @@ impl MediaRepository for HybridRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next Up is dynamic, so the server's answer is preferred — but when the
|
||||
// server cannot answer, the cache's stands in. It used to be server-only,
|
||||
// and the TV landing page loads Next Up in one `Promise.all` with its
|
||||
// other rows, so offline that single failure blanked the whole page with
|
||||
// Continue Watching and Latest sitting in the cache (DR-294).
|
||||
// TRACES: UR-002 | DR-294 | UT-261
|
||||
match self.online.get_next_up_episodes(series_id, limit).await {
|
||||
Ok(items) => Ok(items.without_excluded()),
|
||||
Err(e) => {
|
||||
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
|
||||
self.offline
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
}
|
||||
// Cache-first like every other query: the local answer is computed
|
||||
// from the same watch state the cache refreshes from the server in the
|
||||
// background (`user_data_mirror_query`), and whichever answers first
|
||||
// with content wins. It used to wait for the server outright, which
|
||||
// held the series page's episode list for 2-3 s on a phone. An empty
|
||||
// local answer still defers to the server, and a failed server falls
|
||||
// back to the cache — offline, the TV page's Next Up row must not blank
|
||||
// the page (DR-294).
|
||||
// TRACES: UR-002 | DR-013, DR-294 | UT-261
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let series = series_id.map(str::to_string);
|
||||
let series_for_server = series.clone();
|
||||
|
||||
let cache_future =
|
||||
Self::cache_leg(
|
||||
async move { offline.get_next_up_episodes(series.as_deref(), limit).await },
|
||||
)
|
||||
.await;
|
||||
let server_future = async move {
|
||||
online
|
||||
.get_next_up_episodes(series_for_server.as_deref(), limit)
|
||||
.await
|
||||
};
|
||||
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
|
||||
@@ -287,24 +287,56 @@ pub async fn resolve_series_view(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<SeriesView, RepoError> {
|
||||
let (episodes, next_up, resume) = futures_util::join!(
|
||||
fetch_series_episodes(repo, series_id),
|
||||
async {
|
||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
async {
|
||||
repo.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
);
|
||||
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
|
||||
futures_util::join!(
|
||||
async {
|
||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
async {
|
||||
repo.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let episodes = episodes?;
|
||||
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||
Ok(SeriesView { episodes, current })
|
||||
}
|
||||
|
||||
/// Run `primary` and `hints` together, but never hold `primary` back for
|
||||
/// `hints`: once `primary` is ready, the hints are taken if they have already
|
||||
/// answered and dropped (`H::default()`) if not.
|
||||
///
|
||||
/// For the series view the primary is the episode list and the hints are Next
|
||||
/// Up and resume, which only refine which episode is "current" — and the
|
||||
/// picker falls back to the episodes' own watch state without them. Waiting
|
||||
/// for them made the episode list wait for the server (2-3 s on a phone)
|
||||
/// although every episode was in the cache in 50 ms. The cache legs of the
|
||||
/// hints usually answer before the episodes do, so they are normally kept.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
async fn with_hints<P, H>(
|
||||
primary: impl std::future::Future<Output = P>,
|
||||
hints: impl std::future::Future<Output = H>,
|
||||
) -> (P, H)
|
||||
where
|
||||
H: Default,
|
||||
{
|
||||
use futures_util::future::{select, Either};
|
||||
use futures_util::FutureExt;
|
||||
|
||||
let primary = std::pin::pin!(primary);
|
||||
let hints = std::pin::pin!(hints);
|
||||
match select(primary, hints).await {
|
||||
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
|
||||
Either::Right((hints, primary)) => (primary.await, hints),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
@@ -404,6 +436,55 @@ mod tests {
|
||||
assert_eq!(episodes.len(), 9, "every season but the failing one");
|
||||
}
|
||||
|
||||
/// The episode list must not wait for Next Up or resume.
|
||||
///
|
||||
/// The series page rendered its episodes only once Next Up had come back
|
||||
/// from the server — 2-3 s on a phone while the page's other requests were
|
||||
/// in flight — although every episode was in the cache after 50 ms. Those
|
||||
/// two only refine which episode is "current", and the picker falls back
|
||||
/// to the episodes' own watch state without them.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tokio::test]
|
||||
async fn the_episode_list_does_not_wait_for_slow_hints() {
|
||||
let started = std::time::Instant::now();
|
||||
let (episodes, hints) = with_hints(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
vec![episode("e1", 1, 1)]
|
||||
},
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
|
||||
vec![episode("from-server", 1, 2)]
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_eq!(episodes.len(), 1);
|
||||
assert!(hints.is_empty(), "late hints are dropped, not waited for");
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(500),
|
||||
"the episode list waited {elapsed:?} for Next Up / resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hints that are already in (a cache answer) are used.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tokio::test]
|
||||
async fn hints_that_answer_first_are_kept() {
|
||||
let (_, hints) = with_hints(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
vec![episode("e1", 1, 1)]
|
||||
},
|
||||
async { vec![episode("cached", 1, 2)] },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(hints.len(), 1);
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createCoalescedLoader } from "./coalescedLoader";
|
||||
|
||||
/**
|
||||
* TRACES: UR-062 | DR-295
|
||||
*
|
||||
* The series page loaded itself six times on every open: `onMount` and a
|
||||
* `$effect` both ran on mount, the "server became reachable" effect fired on
|
||||
* its first run, and navigation updates re-ran the effect. Each load repeated
|
||||
* the item, the season list and the whole series view — about six times a
|
||||
* dozen requests in flight at once, which alone slowed every server call on a
|
||||
* phone to 2-3 s.
|
||||
*/
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("createCoalescedLoader", () => {
|
||||
it("shares one run between calls for the same key while it is in flight", async () => {
|
||||
const gate = deferred();
|
||||
const run = vi.fn(() => gate.promise);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
const calls = [1, 2, 3, 4, 5, 6].map(() => loader.load("frasier"));
|
||||
gate.resolve();
|
||||
await Promise.all(calls);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-runs once after the in-flight load when a caller needs fresh data", async () => {
|
||||
const gates = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const run = vi.fn(() => gates[n++].promise);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
const first = loader.load("frasier");
|
||||
// e.g. "mark watched" finished while the page was still loading: the
|
||||
// in-flight load may predate the change, so it must not be the answer.
|
||||
const fresh = loader.load("frasier", { fresh: true });
|
||||
const fresh2 = loader.load("frasier", { fresh: true });
|
||||
gates[0].resolve();
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
gates[1].resolve();
|
||||
// Every caller is answered by the load that includes the re-run.
|
||||
await Promise.all([first, fresh, fresh2]);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share a run between different keys", async () => {
|
||||
const run = vi.fn(() => Promise.resolve());
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await Promise.all([loader.load("frasier"), loader.load("cheers")]);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenNthCalledWith(1, "frasier");
|
||||
expect(run).toHaveBeenNthCalledWith(2, "cheers");
|
||||
});
|
||||
|
||||
it("runs again once the previous load has finished", async () => {
|
||||
const run = vi.fn(() => Promise.resolve());
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await loader.load("frasier");
|
||||
await loader.load("frasier");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("releases the key when a load fails", async () => {
|
||||
const run = vi
|
||||
.fn<(key: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await expect(loader.load("frasier")).rejects.toThrow("offline");
|
||||
await loader.load("frasier");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Load one keyed thing at a time, however many triggers ask for it.
|
||||
*
|
||||
* Calls for the key already loading share that load instead of starting their
|
||||
* own. A caller that knows the data changed (`fresh` — after "mark watched",
|
||||
* on reconnect, when a filter flips) must not be answered by a load that may
|
||||
* predate the change, so it gets exactly one re-run once the current load
|
||||
* ends, however many such callers there were.
|
||||
*
|
||||
* Exists because the series page loaded itself six times on every open —
|
||||
* `onMount`, a mount-time `$effect`, the reachability effect's first run and
|
||||
* navigation updates each started a full load — putting about six times a
|
||||
* dozen requests in flight at once.
|
||||
*
|
||||
* TRACES: UR-062 | DR-295
|
||||
*/
|
||||
export interface CoalescedLoader {
|
||||
/** Load `key`. `fresh`: the caller knows the data changed. */
|
||||
load(key: string, options?: { fresh?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
interface InFlight {
|
||||
key: string;
|
||||
/** Settles when this load and any re-run it owes have finished. */
|
||||
done: Promise<void>;
|
||||
rerun: boolean;
|
||||
}
|
||||
|
||||
export function createCoalescedLoader(run: (key: string) => Promise<void>): CoalescedLoader {
|
||||
let inFlight: InFlight | null = null;
|
||||
|
||||
return {
|
||||
load(key, options = {}) {
|
||||
if (inFlight && inFlight.key === key) {
|
||||
if (options.fresh) inFlight.rerun = true;
|
||||
return inFlight.done;
|
||||
}
|
||||
|
||||
const entry: InFlight = { key, rerun: false, done: Promise.resolve() };
|
||||
entry.done = (async () => {
|
||||
try {
|
||||
await run(key);
|
||||
while (entry.rerun) {
|
||||
entry.rerun = false;
|
||||
await run(key);
|
||||
}
|
||||
} finally {
|
||||
if (inFlight === entry) inFlight = null;
|
||||
}
|
||||
})();
|
||||
inFlight = entry;
|
||||
return entry.done;
|
||||
},
|
||||
};
|
||||
}
|
||||
+24
-23
@@ -73,7 +73,8 @@
|
||||
// a new page inherits the previous page's offset. Must be registered here at
|
||||
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||
let shellScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => shellScroller, "shell");
|
||||
// Owned routes scroll inside their own column; the shell box does not.
|
||||
useScrollRestore(() => (routeOwnsLayout ? undefined : shellScroller), "shell");
|
||||
|
||||
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
||||
@@ -357,29 +358,29 @@
|
||||
scrolling internally. All other top-level pages render directly here, so
|
||||
this wrapper must scroll and reserve the fixed bottom UI's measured
|
||||
height so the mini player / bottom nav never overlap the last rows. -->
|
||||
{#if routeOwnsLayout}
|
||||
<!-- These routes own their own full-height flex column (header + scroller
|
||||
+ their own in-flow BottomUi), so the root just clips and steps back. -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
|
||||
above the scroller, so it never eats into the scroller's bounds. -->
|
||||
{#if showGlobalHeader}
|
||||
<AppHeader />
|
||||
{/if}
|
||||
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
|
||||
sibling, so the list is physically bounded above it and can never
|
||||
render behind it. No measurement, no reserved padding. -->
|
||||
<div
|
||||
bind:this={shellScroller}
|
||||
class="flex-1 overflow-y-auto min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
|
||||
above the scroller, so it never eats into the scroller's bounds. -->
|
||||
{#if !routeOwnsLayout && showGlobalHeader}
|
||||
<AppHeader />
|
||||
{/if}
|
||||
<!-- ONE element renders the route, whatever the layout mode; only its
|
||||
classes change. Routes that own their full-height column (header +
|
||||
scroller + their own in-flow BottomUi) get a plain clipped box; every
|
||||
other route gets the shell scroller (flex-1/min-h-0, bounded above the
|
||||
in-flow BottomUi, so no measurement or reserved padding).
|
||||
|
||||
This used to be two branches, each rendering `children`. The page
|
||||
store that decides the mode can update a flush after the new route
|
||||
renders, so navigating between the two kinds of route (Search → a
|
||||
library page) mounted the page under one branch and then *remounted*
|
||||
it under the other — every load it started, twice (DR-295). -->
|
||||
<div
|
||||
bind:this={shellScroller}
|
||||
class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
|
||||
style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<!-- Re-authentication modal -->
|
||||
<ReauthModal isOpen={$needsReauth} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -51,6 +51,7 @@
|
||||
type SeasonData,
|
||||
} from "$lib/components/library/seriesNavigation";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
|
||||
|
||||
const log = createLogger("LibraryDetail");
|
||||
|
||||
@@ -65,18 +66,27 @@
|
||||
// preference, so it resets with each load (DR-107).
|
||||
let expandedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
// Track if we've done an initial load and previous server state
|
||||
// Track if we've done an initial load and previous server state. The
|
||||
// previous state starts as "unknown" (null): the effect's first run only
|
||||
// records it. Starting at `false` made that first run look like a
|
||||
// reconnect and force a second, fresh load of the page on every open.
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
let previousServerReachable: boolean | null = null;
|
||||
|
||||
const itemId = $derived($page.params.id);
|
||||
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
||||
|
||||
onMount(async () => {
|
||||
await loadItem();
|
||||
hasLoadedOnce = true;
|
||||
});
|
||||
// Every trigger below goes through one coalesced loader: they used to each
|
||||
// start a full load, so opening a page loaded it six times over (DR-295).
|
||||
// `fresh` marks triggers that know the data changed.
|
||||
const loader = createCoalescedLoader(() => loadItemNow());
|
||||
function loadItem(options?: { fresh?: boolean }): Promise<void> {
|
||||
if (!itemId) return Promise.resolve();
|
||||
return loader.load(itemId, options);
|
||||
}
|
||||
const reloadFresh = () => loadItem({ fresh: true });
|
||||
|
||||
// Runs on mount and whenever the item changes.
|
||||
$effect(() => {
|
||||
if (itemId) {
|
||||
loadItem();
|
||||
@@ -89,8 +99,8 @@
|
||||
const serverReachable = $isServerReachable;
|
||||
|
||||
// If server just became reachable and we've already loaded, reload to get fresh data
|
||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
|
||||
loadItem();
|
||||
if (serverReachable && previousServerReachable === false && hasLoadedOnce && itemId) {
|
||||
reloadFresh();
|
||||
}
|
||||
|
||||
previousServerReachable = serverReachable;
|
||||
@@ -100,10 +110,10 @@
|
||||
// contents follow the filter the same way a library listing does.
|
||||
// TRACES: UR-052 | DR-143
|
||||
useOfflineFilterReload(() => {
|
||||
if (itemId) loadItem();
|
||||
if (itemId) reloadFresh();
|
||||
});
|
||||
|
||||
async function loadItem() {
|
||||
async function loadItemNow() {
|
||||
if (!itemId) return;
|
||||
// Only show spinner when navigating to a different item
|
||||
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
||||
@@ -579,13 +589,13 @@
|
||||
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
|
||||
scope="series"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
onChanged={reloadFresh}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
scope="series"
|
||||
onCleared={loadItem}
|
||||
onCleared={reloadFresh}
|
||||
/>
|
||||
{:else if item.kind === "movie"}
|
||||
<VideoDownloadButton
|
||||
@@ -600,7 +610,7 @@
|
||||
watched={item.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
onChanged={reloadFresh}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||
@@ -732,7 +742,7 @@
|
||||
expanded={expandedSeasons.has(season.id)}
|
||||
onToggle={() => toggleSeason(season.id)}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
onHistoryCleared={loadItem}
|
||||
onHistoryCleared={reloadFresh}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user