fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
This commit is contained in:
@@ -83,6 +83,60 @@ describe("useOpenLinkedPlayer", () => {
|
||||
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a player opened while the row was on its way alone, and its address", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
let answer: (v: Video) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
// A card clicked meanwhile: its player names itself in the address.
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
answer(vid("Z"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("does not clear the address of a player opened since when its catalog read fails", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
let refuse: (e: Error) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
refuse(new Error("not found"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("given up over a moved list, keeps the address of a player opened since", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const { rerender } = renderHook(
|
||||
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
|
||||
{ wrapper, initialProps: { scope: 1 } },
|
||||
);
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
rerender({ scope: 2 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=B");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
|
||||
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
|
||||
const { rerender } = renderHook(
|
||||
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
|
||||
{ wrapper, initialProps: { rows: true } },
|
||||
);
|
||||
await waitFor(() => expect(find).toHaveBeenCalled());
|
||||
find.mockClear();
|
||||
rerender({ rows: false });
|
||||
rerender({ rows: true });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
|
||||
window.history.replaceState(null, "", "/?q=talks");
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
|
||||
@@ -14,7 +14,8 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
*
|
||||
* `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. */
|
||||
* opened later, it would start over a list nobody linked, unasked. So it is when a player opened
|
||||
* meanwhile (a card clicked while the row was on its way): the address names that one now. */
|
||||
export function useOpenLinkedPlayer(
|
||||
ready: boolean,
|
||||
find: (id: string) => Video | undefined,
|
||||
@@ -24,14 +25,17 @@ export function useOpenLinkedPlayer(
|
||||
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.
|
||||
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
|
||||
// not open it twice); the state stops the lookup and the row fetch on screen after it.
|
||||
const done = useRef(linked == null);
|
||||
const [settled, setSettled] = useState(linked == null);
|
||||
const moved = scope !== linkedScope;
|
||||
const listed = ready && !moved && linked != null ? find(linked) : undefined;
|
||||
const waiting = ready && !moved && !settled && linked != null;
|
||||
const listed = waiting ? find(linked) : undefined;
|
||||
const row = useQuery({
|
||||
queryKey: qk.videoRow(linked ?? ""),
|
||||
queryFn: () => api.videoRow(linked!),
|
||||
enabled: ready && !moved && linked != null && !listed,
|
||||
enabled: waiting && !listed,
|
||||
retry: false,
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
@@ -41,12 +45,18 @@ export function useOpenLinkedPlayer(
|
||||
});
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
if (moved) {
|
||||
const settle = () => {
|
||||
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;
|
||||
// State, not derived: a one-way decision taken as the list or the row arrives. Derived from
|
||||
// `listed`, the lookup and the row fetch would come back whenever the video left the list.
|
||||
setSettled(true);
|
||||
};
|
||||
// A player opened since names its own: this one is not wanted any more.
|
||||
if (linkedPlayer()?.watch !== linked) return settle();
|
||||
if (moved) {
|
||||
// The address goes on naming it under the new list (a reload would open it there).
|
||||
clearParams(...PLAYER_KEYS);
|
||||
return settle();
|
||||
}
|
||||
// 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
|
||||
@@ -56,6 +66,6 @@ export function useOpenLinkedPlayer(
|
||||
else if (row.data) openIt(row.data, false);
|
||||
else if (row.isError) failed(row.error);
|
||||
else return;
|
||||
done.current = true;
|
||||
settle();
|
||||
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user