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
@@ -297,7 +297,9 @@ describe("Activity", () => {
it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here.
// renders on its own; a test dispatch does not. Each listener runs in its own act here — a
// synchronous one: a browser runs no TASK between them (an async act let a timer set by one
// listener fire before the next).
const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window);
@@ -316,7 +318,7 @@ describe("Activity", () => {
// Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate")));
act(() => l(new PopStateEvent("popstate")));
}
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
+1
View File
@@ -14,4 +14,5 @@ export const activityModule: ModuleDef = {
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Activity")),
urlKeys: ACTIVITY_KEYS,
typedUrlKeys: ["aq"],
};
+1
View File
@@ -15,4 +15,5 @@ export const channelsModule: ModuleDef = {
maxWidth: "96rem",
Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS,
typedUrlKeys: ["cq"],
};
+2 -2
View File
@@ -16,7 +16,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
import { LS, useTabPersistedState } from "../../lib/storage";
import { CONFIG_TAB_KEY } from "../../lib/configTab";
import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
@@ -192,7 +192,7 @@ export default function ConfigPanel() {
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
// The address names the group on screen (always: see SettingsPanel); until the registry is in, the
// one it was opened on.
useUrlMirror({ [CONFIG_TAB_KEY]: (data ? activeGroup : tab) || null });
useUrlMirror(configTabParams((data ? activeGroup : tab) || null));
if (q.isError && !data) {
return (
+12 -11
View File
@@ -503,6 +503,10 @@ export default function Feed({
const saved = savedOverrides[v.id];
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
});
// The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -511,16 +515,18 @@ export default function Feed({
// goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
// (the feed's filters are in the address under it). Not over a live search: that is not in the
// address, and a reload leaves it.
// (the feed's filters are in the address under it) — over a live search too, its results the
// queue, once they are in (or the search waits for its press, and it plays alone). Scoped to the
// list it was linked over: leaving the search first gives it up.
useOpenLinkedPlayer(
!ytActive && !query.isLoading,
(id) => playerQueue.find((v) => v.id === id),
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
(id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id),
(video, inList) => {
openVideo(video);
// Not in what this list has loaded (further down it, or no longer in it): it plays alone.
if (!inList) setActiveVideo((a) => a && { ...a, alone: true });
},
ytActive ? `yt:${ytSearch}` : "feed",
);
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
@@ -534,12 +540,7 @@ export default function Feed({
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded, true),
ytSort.key as SortKey,
ytSort.dir,
lang,
);
const ytItems: Video[] = ytShown(ytLoaded);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError =
ytQuery.error instanceof HttpError
@@ -704,7 +705,7 @@ export default function Feed({
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={ytPlayerQueue}
queue={activeVideo.alone ? undefined : ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
@@ -24,6 +24,14 @@ vi.mock("./VirtualFeed", () => ({
),
}));
vi.mock("../../features/player/PlayerModal", () => ({
default: ({ video, queue }: { video: { id: string }; queue?: { id: string }[] }) => (
<p>
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
</p>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -48,6 +56,7 @@ beforeAll(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
const FILTERS: FeedFilters = {
@@ -106,14 +115,14 @@ function mount({
search_source: source,
search_enabled: enabled,
});
const tree = (count: number) => (
const tree = (count: number, ytSearch: string | null = "lofi") => (
<QueryClientProvider client={qc}>
<NavigationActionsContext.Provider value={nav}>
<NavigationStateContext.Provider
value={{
page: "feed",
channelView: null,
ytSearch: "lofi",
ytSearch,
ytCount: count,
ytSearchAsked: asked,
}}
@@ -124,7 +133,7 @@ function mount({
view="cards"
setView={() => {}}
canRead
ytSearch="lofi"
ytSearch={ytSearch}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
@@ -133,7 +142,10 @@ function mount({
</QueryClientProvider>
);
const view = render(tree(20));
return { showing: (count: number) => view.rerender(tree(count)) };
return {
showing: (count: number) => view.rerender(tree(count)),
leave: () => view.rerender(tree(20, null)),
};
}
const settle = () => new Promise((r) => setTimeout(r, 30));
@@ -216,3 +228,41 @@ describe("Feed — a live search the address reopened", () => {
expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
});
});
// A player opened over the search names itself in the address too: a reload or a bookmark opens it
// again over the search it was opened from — not later, over a list nobody linked.
describe("Feed — a player the address names over a live search", () => {
it("reopens over the results, them as its queue", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a"), hit("b")],
next_cursor: null,
source: "scrape",
} as never);
const row = vi.spyOn(api, "videoRow");
mount({ source: "scrape", asked: false });
expect(await screen.findByText("playing b in a,b")).toBeTruthy();
expect(row).not.toHaveBeenCalled();
});
it("plays alone over a search that waits for its press", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
mount({ source: "api", asked: false });
expect(await screen.findByText("playing b in no list")).toBeTruthy();
});
it("is given up when the search is left before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "feed").mockResolvedValue({ items: [hit("b")], next_cursor: null } as never);
const row = vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
const { leave } = mount({ source: "scrape", asked: false });
leave();
await settle();
await settle();
expect(screen.queryByText(/^playing/)).toBeNull();
expect(row).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { type FeedFilters } from "../../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext";
@@ -56,8 +57,11 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
// feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own.
// The search text once the typing pauses, as the other pages' searches: one address-bar write per
// keystroke is churn a browser throttles.
const nav = useNavigation();
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null));
const typedQ = useDebounced(filters.q ?? "", 300);
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? { ...filters, q: typedQ } : null));
// A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload.
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The feed's address names its view. The view and the order always (defaults too): an address naming
// none falls back to the view the account stored, so a bookmark of the default feed reopened whatever
// view was stored since. The search text once the typing pauses, as the other pages' searches.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, LS } from "../../lib/storage";
import type { FeedFilters } from "../../lib/api";
const seen: { filters: FeedFilters | null; set: ((f: FeedFilters) => void) | null } = {
filters: null,
set: null,
};
function Probe() {
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
useEffect(() => {
seen.filters = filters;
seen.set = setFilters;
});
return null;
}
function mount() {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: {} });
render(
<QueryClientProvider client={qc}>
<NavigationProvider>
<FeedFiltersProvider>
<Probe />
</FeedFiltersProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("FeedFiltersProvider · the address", () => {
it("names the default view and order", () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
expect([search().get("show"), search().get("sort")]).toEqual(["unwatched", "newest"]);
});
it("a bookmark of the default feed opens it, not the view the account stored since", () => {
localStorage.setItem(LS.filters, JSON.stringify({ show: "all", includeShorts: true }));
window.history.replaceState(null, "", "/?page=feed&show=unwatched&sort=newest");
mount();
expect(seen.filters!.show).toBe("unwatched");
expect(seen.filters!.includeShorts).toBe(false);
});
it("names the search once the typing pauses", async () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
act(() => seen.set!({ ...seen.filters!, q: "lo" }));
act(() => seen.set!({ ...seen.filters!, q: "lofi" }));
expect(search().get("q")).toBeNull();
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(search().get("q")).toBe("lofi");
});
});
+1
View File
@@ -19,6 +19,7 @@ export const feedModule: ModuleDef = {
Store: FeedStore,
// Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState).
urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS],
typedUrlKeys: ["q", "yt"],
playsVideos: true,
};
+2 -2
View File
@@ -14,7 +14,7 @@ import { formatBytes } from "../../lib/format";
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
import { FILES_VIEW_KEY } from "../../lib/filesUrlState";
import { filesOpenParams } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { ContextMenu } from "../../components/ui/ContextMenu";
@@ -129,7 +129,7 @@ export default function FilePreview({
title={<span className="block truncate">{title}</span>}
onClose={onClose}
maxWidth="max-w-6xl"
url={{ [FILES_VIEW_KEY]: String(file.id) }}
url={filesOpenParams({ open: "view", id: file.id })}
headerActions={
<>
<IconButton
@@ -809,6 +809,26 @@ describe("Files · a file the address names", () => {
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
expect(here().has("fview")).toBe(false);
});
// A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not
// among the ones listed, and keeps the link.
it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => {
// The case before closed a viewer: its step back lands a moment later, on whatever is current.
await new Promise((r) => setTimeout(r, 30));
window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.arrival.notListed" }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
});
describe("Files · the view", () => {
+8 -5
View File
@@ -25,11 +25,12 @@ import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { dropFromAddress } from "../../lib/urlState";
import { clearParams } from "../../lib/urlState";
import {
FILES_PLAY_KEY,
FILES_SORT_KEY,
FILES_VIEW_KEY,
filesSortParams,
openFileFrom,
} from "../../lib/filesUrlState";
import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -231,7 +232,7 @@ export default function Files() {
setLinkedSort(undefined);
setPrefSort(next);
};
useUrlMirror({ [FILES_SORT_KEY]: encodeSortParamOrNone(sort) });
useUrlMirror(filesSortParams(encodeSortParamOrNone(sort)));
const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
@@ -286,15 +287,17 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null);
// A reload or a bookmark reopens the file the address names — in the viewer or the player — once
// the list it is in has come (its folder, search and sort are in the address too). Once; a file the
// list no longer holds is said, and taken off the address.
// list no longer holds is said, and taken off the address — unless the cap cut the list, which then
// cannot tell a deleted file from one beyond its end (as an arrival's notice says): the link stays.
const [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search)));
const linkedDone = useRef(linkedFile == null);
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
if (!file) {
if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") });
else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") });
dropFromAddress([FILES_VIEW_KEY, FILES_PLAY_KEY]);
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
} else if (linkedFile?.open === "play") play(file);
else setViewing(file.id);
});
+16 -9
View File
@@ -2,7 +2,12 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY, FILES_SEARCH_KEY } from "../../lib/filesUrlState";
import {
FILES_FOLDER_KEY,
FILES_ITEM_KEY,
FILES_SEARCH_KEY,
filesListParams,
} from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
@@ -24,8 +29,8 @@ import {
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>.
//
// The search lives in the address alone (`?fq=`): a reload or a bookmark opens it searched, and a trip
// back to the page through the rail opens it unsearched. The view is remembered per account and
// The search is named in the address (`?fq=`), so a reload or a bookmark opens it searched, and ends
// when the page is left: a trip back to the page through the rail opens it unsearched. The view is remembered per account and
// mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A
// `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
@@ -89,19 +94,21 @@ export function FilesProvider({ children }: { children: ReactNode }) {
);
// Only ON the Files page, and only until the page has used it.
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to.
// Leaving the page ends the arrival — back on it later, nothing is rung or turned to — and the
// search: the page opens unsearched again.
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
if (page !== "files" && search) setSearchState("");
const arrived = page === "files" ? arrivedRaw : null;
const shown = urlPage(nav) === "files";
// Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles);
// an arrival's cleared box at once.
const typed = useDebounced(search.trim(), 300, focusRaw?.token);
useUrlMirror({
[FILES_SEARCH_KEY]: shown && typed ? typed : null,
[FILES_FOLDER_KEY]: shown ? viewParam(view) : null,
[FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null,
});
useUrlMirror(
filesListParams(
shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null,
),
);
const state = useMemo<FilesState>(
() => ({ search, view, focus, arrived }),
+2 -2
View File
@@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import Modal from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { FILES_PLAY_KEY } from "../../lib/filesUrlState";
import { filesOpenParams } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle";
import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../../lib/mediaLog";
@@ -97,7 +97,7 @@ export default function LibraryPlayer({
title={title}
onClose={onClose}
maxWidth="max-w-4xl"
url={{ [FILES_PLAY_KEY]: String(job.id) }}
url={filesOpenParams({ open: "play", id: job.id })}
>
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
+1
View File
@@ -18,4 +18,5 @@ export const filesModule: ModuleDef = {
maxWidth: "80rem",
Store: FilesProvider,
urlKeys: FILES_KEYS,
typedUrlKeys: ["fq"],
};
@@ -211,6 +211,32 @@ describe("Playlists — a player the address names", () => {
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
expect(h.player!.onRemoveFromList).toBeUndefined();
});
it("plays alone over a playlist emptied since", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
mount({ 1: [] });
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
});
it("is given up when another playlist is picked before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
// The linked playlist's read never answers: the opener is still waiting when the pick lands.
const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("B", 0)] });
vi.mocked(api.playlist).mockImplementation(async (id: number) =>
id === 1 ? new Promise<never>(() => {}) : (list(id, [vid("B", 0)]) as never),
);
qc.removeQueries({ queryKey: qk.playlist(1) });
h.selectedId = 2;
rerender();
await screen.findByText("Video B");
await new Promise((r) => setTimeout(r, 30));
expect(h.player).toBeNull();
expect(row).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
});
});
describe("Playlists — the row the player is launched with", () => {
+9 -2
View File
@@ -583,14 +583,21 @@ function PlaylistDetail() {
});
// A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue
// again (the playlist and its view are in the address under it). A video the view does not show
// (filtered out, gone from the list) plays alone, and nothing removes it from a list.
// (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
const linkedListIn =
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
(selectedId != null && detailQuery.isError) ||
(selectedId == null && listQuery.data?.length === 0);
useOpenLinkedPlayer(
detail != null && items.length > 0,
linkedListIn,
(id) => shown.find((v) => v.id === id),
(video, inList) => {
if (inList) openPlayer(video.id);
else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false });
},
selectedId,
);
async function moveItemTo(video: Video, target: Playlist) {
@@ -5,6 +5,7 @@ import { useDebounced } from "../../lib/useDebounced";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import { readSelectedId } from "./playlistsInit";
import { playlistsParams } from "../../lib/playlistsUrlState";
import {
PlaylistsActionsContext,
PlaylistsStateContext,
@@ -18,8 +19,8 @@ import {
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>).
//
// The `search` term lives in the address alone (`?plq=`, a reload or a bookmark keeps it, a trip back
// through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so
// The `search` term is named in the address (`?plq=`, a reload or a bookmark keeps it) and ends when
// the page is left (a trip back through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so
// a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
//
@@ -43,11 +44,10 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
// The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles.
const nav = useNavigation();
const shown = urlPage(nav) === "playlists";
// Leaving the page ends the search: adjusted during the render that leaves it.
if (nav.page !== "playlists" && search) setSearchState("");
const typed = useDebounced(search.trim(), 300);
useUrlMirror({
pl: shown && selectedId != null ? String(selectedId) : null,
plq: shown && typed ? typed : null,
});
useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, ""));
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
+1
View File
@@ -19,5 +19,6 @@ export const playlistsModule: ModuleDef = {
Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: PLAYLISTS_KEYS,
typedUrlKeys: ["plq"],
playsVideos: true,
};
+1
View File
@@ -19,4 +19,5 @@ export const plexModule: ModuleDef = {
maxWidth: "100rem",
Store: PlexStore,
urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY],
typedUrlKeys: ["pq"],
};
@@ -26,7 +26,7 @@ import { api, HttpError, type Me } from "../../lib/api";
import { displayName, roleKey } from "../../lib/api/account";
import { notifyActionError } from "../../lib/actionErrors";
import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY } from "../../lib/settingsTab";
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications";
@@ -80,7 +80,7 @@ export default function SettingsPanel() {
: "appearance";
// The address names the tab on screen, so a bookmark opens it (always written: without it, the page
// opens on the tab this browser tab last showed).
useUrlMirror({ [SETTINGS_TAB_KEY]: tab });
useUrlMirror(settingsTabParams(tab));
return (
<div className="p-4 max-w-3xl w-full mx-auto">
+2 -2
View File
@@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account";
import { notify } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
@@ -89,7 +89,7 @@ export default function AdminUsers() {
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
// The address names the tab on screen (always: see SettingsPanel).
useUrlMirror({ [USERS_TAB_URL_KEY]: active });
useUrlMirror(usersTabParams(active));
return (
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}