fix(nav): the address follows the view through overlays, reloads and Back (review round 1)

- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
This commit is contained in:
2026-10-27 13:52:42 +01:00
parent 7b4e007680
commit 0d5c6273f1
50 changed files with 928 additions and 157 deletions
@@ -4,43 +4,58 @@ import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { dropFromAddress } from "../../lib/urlState";
import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
* the catalog. A row the catalog no longer has is said, not swallowed. */
* the catalog. A row the catalog no longer has is said, not swallowed.
*
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to
* another before it opened, the address under it named something else, and the player is given up —
* opened later, it would start over a list nobody linked, unasked. */
export function useOpenLinkedPlayer(
ready: boolean,
find: (id: string) => Video | undefined,
open: (video: Video, inList: boolean) => void,
scope: string | number | null = null,
): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope);
// Opened (or said to be gone) once; a ref, as nothing on screen reads it.
const done = useRef(linked == null);
const listed = ready && linked != null ? find(linked) : undefined;
const moved = scope !== linkedScope;
const listed = ready && !moved && linked != null ? find(linked) : undefined;
const row = useQuery({
queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!),
enabled: ready && linked != null && !listed,
enabled: ready && !moved && linked != null && !listed,
retry: false,
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed"));
dropFromAddress(PLAYER_KEYS);
clearParams(...PLAYER_KEYS);
});
useEffect(() => {
if (done.current) return;
if (moved) {
done.current = true;
// The address goes on naming it under the new list (a reload would open it there) — unless a
// player opened since names its own.
if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS);
return;
}
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
// it here too (a disabled query still reads the cache) — the shell's player over a page that
// plays its own would open a second one.
if (done.current || !ready) return;
if (!ready) return;
if (listed) openIt(listed, true);
else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error);
else return;
done.current = true;
}, [ready, listed, row.data, row.isError, row.error]);
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
}