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
|
restructure the shell, preserve the scroll containment — reintroducing padding
|
||||||
math reintroduces the bug.
|
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
|
### AccountMenu
|
||||||
|
|
||||||
One component for both breakpoints, anchored to the username/avatar (a real
|
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
|
A season is not a destination: `/library/<seasonId>` redirects to its series
|
||||||
(DR-103). Video library routes collapse to one per library (DR-105).
|
(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
|
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
||||||
from the component because it had three distinct bugs that markup made
|
from the component because it had three distinct bugs that markup made
|
||||||
untestable: the strip collapsing to just the current episode while real siblings
|
untestable: the strip collapsing to just the current episode while real siblings
|
||||||
|
|||||||
@@ -1000,22 +1000,32 @@ impl MediaRepository for HybridRepository {
|
|||||||
series_id: Option<&str>,
|
series_id: Option<&str>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Next Up is dynamic, so the server's answer is preferred — but when the
|
// Cache-first like every other query: the local answer is computed
|
||||||
// server cannot answer, the cache's stands in. It used to be server-only,
|
// from the same watch state the cache refreshes from the server in the
|
||||||
// and the TV landing page loads Next Up in one `Promise.all` with its
|
// background (`user_data_mirror_query`), and whichever answers first
|
||||||
// other rows, so offline that single failure blanked the whole page with
|
// with content wins. It used to wait for the server outright, which
|
||||||
// Continue Watching and Latest sitting in the cache (DR-294).
|
// held the series page's episode list for 2-3 s on a phone. An empty
|
||||||
// TRACES: UR-002 | DR-294 | UT-261
|
// local answer still defers to the server, and a failed server falls
|
||||||
match self.online.get_next_up_episodes(series_id, limit).await {
|
// back to the cache — offline, the TV page's Next Up row must not blank
|
||||||
Ok(items) => Ok(items.without_excluded()),
|
// the page (DR-294).
|
||||||
Err(e) => {
|
// TRACES: UR-002 | DR-013, DR-294 | UT-261
|
||||||
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
|
let offline = Arc::clone(&self.offline);
|
||||||
self.offline
|
let online = Arc::clone(&self.online);
|
||||||
.get_next_up_episodes(series_id, limit)
|
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
|
.await
|
||||||
.map(ExcludeHidden::without_excluded)
|
};
|
||||||
}
|
|
||||||
}
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_recently_played_audio(
|
async fn get_recently_played_audio(
|
||||||
|
|||||||
@@ -287,8 +287,8 @@ pub async fn resolve_series_view(
|
|||||||
repo: &dyn MediaRepository,
|
repo: &dyn MediaRepository,
|
||||||
series_id: &str,
|
series_id: &str,
|
||||||
) -> Result<SeriesView, RepoError> {
|
) -> Result<SeriesView, RepoError> {
|
||||||
let (episodes, next_up, resume) = futures_util::join!(
|
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
|
||||||
fetch_series_episodes(repo, series_id),
|
futures_util::join!(
|
||||||
async {
|
async {
|
||||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||||
.await
|
.await
|
||||||
@@ -299,12 +299,44 @@ pub async fn resolve_series_view(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let episodes = episodes?;
|
let episodes = episodes?;
|
||||||
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||||
Ok(SeriesView { episodes, current })
|
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.
|
/// Resolve the current episode, fetching everything the policy needs.
|
||||||
///
|
///
|
||||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
/// 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");
|
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 {
|
fn watched(mut item: MediaItem) -> MediaItem {
|
||||||
item.user_data = Some(UserData {
|
item.user_data = Some(UserData {
|
||||||
is_played: Some(true),
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-15
@@ -73,7 +73,8 @@
|
|||||||
// a new page inherits the previous page's offset. Must be registered here at
|
// 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)
|
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||||
let shellScroller = $state<HTMLElement>();
|
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
|
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||||
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
// ($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
|
scrolling internally. All other top-level pages render directly here, so
|
||||||
this wrapper must scroll and reserve the fixed bottom UI's measured
|
this wrapper must scroll and reserve the fixed bottom UI's measured
|
||||||
height so the mini player / bottom nav never overlap the last rows. -->
|
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
|
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
|
||||||
above the scroller, so it never eats into the scroller's bounds. -->
|
above the scroller, so it never eats into the scroller's bounds. -->
|
||||||
{#if showGlobalHeader}
|
{#if !routeOwnsLayout && showGlobalHeader}
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
|
<!-- ONE element renders the route, whatever the layout mode; only its
|
||||||
sibling, so the list is physically bounded above it and can never
|
classes change. Routes that own their full-height column (header +
|
||||||
render behind it. No measurement, no reserved padding. -->
|
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
|
<div
|
||||||
bind:this={shellScroller}
|
bind:this={shellScroller}
|
||||||
class="flex-1 overflow-y-auto min-h-0"
|
class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
|
||||||
style="overscroll-behavior: contain"
|
style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Re-authentication modal -->
|
<!-- Re-authentication modal -->
|
||||||
<ReauthModal isOpen={$needsReauth} />
|
<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 -->
|
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
type SeasonData,
|
type SeasonData,
|
||||||
} from "$lib/components/library/seriesNavigation";
|
} from "$lib/components/library/seriesNavigation";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
|
||||||
|
|
||||||
const log = createLogger("LibraryDetail");
|
const log = createLogger("LibraryDetail");
|
||||||
|
|
||||||
@@ -65,18 +66,27 @@
|
|||||||
// preference, so it resets with each load (DR-107).
|
// preference, so it resets with each load (DR-107).
|
||||||
let expandedSeasons = $state<Set<string>>(new Set());
|
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 hasLoadedOnce = false;
|
||||||
let previousServerReachable = false;
|
let previousServerReachable: boolean | null = null;
|
||||||
|
|
||||||
const itemId = $derived($page.params.id);
|
const itemId = $derived($page.params.id);
|
||||||
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
||||||
|
|
||||||
onMount(async () => {
|
// Every trigger below goes through one coalesced loader: they used to each
|
||||||
await loadItem();
|
// start a full load, so opening a page loaded it six times over (DR-295).
|
||||||
hasLoadedOnce = true;
|
// `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(() => {
|
$effect(() => {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
loadItem();
|
loadItem();
|
||||||
@@ -89,8 +99,8 @@
|
|||||||
const serverReachable = $isServerReachable;
|
const serverReachable = $isServerReachable;
|
||||||
|
|
||||||
// If server just became reachable and we've already loaded, reload to get fresh data
|
// If server just became reachable and we've already loaded, reload to get fresh data
|
||||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
|
if (serverReachable && previousServerReachable === false && hasLoadedOnce && itemId) {
|
||||||
loadItem();
|
reloadFresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
previousServerReachable = serverReachable;
|
previousServerReachable = serverReachable;
|
||||||
@@ -100,10 +110,10 @@
|
|||||||
// contents follow the filter the same way a library listing does.
|
// contents follow the filter the same way a library listing does.
|
||||||
// TRACES: UR-052 | DR-143
|
// TRACES: UR-052 | DR-143
|
||||||
useOfflineFilterReload(() => {
|
useOfflineFilterReload(() => {
|
||||||
if (itemId) loadItem();
|
if (itemId) reloadFresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadItem() {
|
async function loadItemNow() {
|
||||||
if (!itemId) return;
|
if (!itemId) return;
|
||||||
// Only show spinner when navigating to a different item
|
// Only show spinner when navigating to a different item
|
||||||
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
// 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)}
|
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
|
||||||
scope="series"
|
scope="series"
|
||||||
showLabel={true}
|
showLabel={true}
|
||||||
onChanged={loadItem}
|
onChanged={reloadFresh}
|
||||||
/>
|
/>
|
||||||
<ClearHistoryButton
|
<ClearHistoryButton
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
itemName={item.name}
|
itemName={item.name}
|
||||||
scope="series"
|
scope="series"
|
||||||
onCleared={loadItem}
|
onCleared={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{:else if item.kind === "movie"}
|
{:else if item.kind === "movie"}
|
||||||
<VideoDownloadButton
|
<VideoDownloadButton
|
||||||
@@ -600,7 +610,7 @@
|
|||||||
watched={item.userData?.isPlayed ?? false}
|
watched={item.userData?.isPlayed ?? false}
|
||||||
scope="episode"
|
scope="episode"
|
||||||
showLabel={true}
|
showLabel={true}
|
||||||
onChanged={loadItem}
|
onChanged={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||||
@@ -732,7 +742,7 @@
|
|||||||
expanded={expandedSeasons.has(season.id)}
|
expanded={expandedSeasons.has(season.id)}
|
||||||
onToggle={() => toggleSeason(season.id)}
|
onToggle={() => toggleSeason(season.id)}
|
||||||
onEpisodeClick={handleEpisodeClick}
|
onEpisodeClick={handleEpisodeClick}
|
||||||
onHistoryCleared={loadItem}
|
onHistoryCleared={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user