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:
@@ -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),
|
||||
|
||||
@@ -14,4 +14,5 @@ export const activityModule: ModuleDef = {
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Activity")),
|
||||
urlKeys: ACTIVITY_KEYS,
|
||||
typedUrlKeys: ["aq"],
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@ export const channelsModule: ModuleDef = {
|
||||
maxWidth: "96rem",
|
||||
Store: ChannelsProvider,
|
||||
urlKeys: CHANNELS_KEYS,
|
||||
typedUrlKeys: ["cq"],
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -19,5 +19,6 @@ export const playlistsModule: ModuleDef = {
|
||||
Store: PlaylistsProvider,
|
||||
// The playlist shown (PlaylistsProvider, playlistsInit).
|
||||
urlKeys: PLAYLISTS_KEYS,
|
||||
typedUrlKeys: ["plq"],
|
||||
playsVideos: true,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user