feat(player): the YouTube player and the file viewer are named in the address

The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
This commit is contained in:
2026-10-27 05:29:49 +01:00
parent d4f24de4ba
commit fa893c73a3
45 changed files with 1005 additions and 89 deletions
+15
View File
@@ -249,6 +249,21 @@ def set_video_progress(
return {"ok": True, "video_id": video_id, "position_seconds": position} return {"ok": True, "video_id": video_id, "position_seconds": position}
@router.get("/videos/{video_id}/row")
def get_video_row(
video_id: str = Path(max_length=32),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""One catalog video as the feed lists it (its channel, this user's watch state and resume point,
saved): what a player opened from an address starts from. The catalog only, so it costs nothing;
every video a player can open (a list's row, a related one) is in it."""
rows = queries.feed_rows_by_ids(db, user, [video_id])
if not rows:
raise not_found("video")
return rows[0]
@router.get("/videos/{video_id}") @router.get("/videos/{video_id}")
def get_video_detail( def get_video_detail(
video_id: str, video_id: str,
+47
View File
@@ -0,0 +1,47 @@
"""A catalog video as the feed lists it, the row a player opened from an address starts from. DB lane."""
from datetime import datetime, timezone
from app.catalog.models import Channel, Video
from app.reader.models import VideoState
def _video(db, vid="v1"):
db.add(Channel(id="UCo", title="Owner", handle="@owner"))
db.flush()
db.add(
Video(
id=vid,
title="A talk",
channel_id="UCo",
published_at=datetime.now(timezone.utc),
duration_seconds=3600,
)
)
db.commit()
def test_a_video_comes_as_the_feed_row_with_my_own_state(db, make_user, as_user):
me, other = make_user(), make_user()
_video(db)
db.add(VideoState(user_id=me.id, video_id="v1", status="new", position_seconds=600))
db.add(VideoState(user_id=other.id, video_id="v1", status="watched", position_seconds=90))
db.commit()
r = as_user(me).get("/api/videos/v1/row")
assert r.status_code == 200, r.text
row = r.json()
assert (row["id"], row["title"], row["channel_title"]) == ("v1", "A talk", "Owner")
assert (row["status"], row["position_seconds"]) == ("new", 600)
assert row["watch_url"] == "https://www.youtube.com/watch?v=v1"
def test_a_video_never_opened_has_no_state(db, make_user, as_user):
_video(db)
row = as_user(make_user()).get("/api/videos/v1/row").json()
assert (row["status"], row["position_seconds"], row["saved"]) == ("new", 0, False)
def test_a_video_outside_the_catalog_is_not_found(db, make_user, as_user):
r = as_user(make_user()).get("/api/videos/nope/row")
assert r.status_code == 404
assert r.json()["detail"]["code"] == "not_found"
+6 -1
View File
@@ -65,6 +65,7 @@ export default function Modal({
placement = "center", placement = "center",
initialFocus, initialFocus,
headerActions, headerActions,
url,
}: { }: {
title: ReactNode; title: ReactNode;
initialFocus: ModalFocus; initialFocus: ModalFocus;
@@ -81,10 +82,14 @@ export default function Modal({
placement?: "center" | "drawer" | "drawer-start" | "top"; placement?: "center" | "drawer" | "drawer-start" | "top";
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
headerActions?: ReactNode; headerActions?: ReactNode;
/** What the dialog names in the address while it is open, when it is part of the view (a viewer, a
* player): written into its own history entry, so a reload or a bookmark reopens it and Back lands
* on the address without it (lib/history). */
url?: Record<string, string | null>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose); useBackToClose(onClose, url);
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen // ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
// element — and attached before the children commit, so an `autoFocus` child // element — and attached before the children commit, so an `autoFocus` child
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it. // still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
@@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider"; import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext"; import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage"; import { urlPage } from "./urlPage";
import { channelUrl } from "../../lib/urlState";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings"; import { PageHeading } from "../../components/ui/Headings";
@@ -280,6 +281,22 @@ describe("NavigationProvider · the channel page's address", () => {
}); });
}); });
// The YouTube player opens over any page and names itself in its own entry (lib/playerUrlState).
describe("NavigationProvider · the player's address", () => {
it("a page switch takes a player the address still names with it", () => {
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
const { result } = render();
act(() => result.current.setPage("stats"));
expect(window.location.search).toBe("?page=stats");
});
it("a channel link names no player: opening the channel closes it", () => {
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
render();
expect(channelUrl("UC1")).toBe("/?chan=UC1");
});
});
describe("NavigationProvider · Back", () => { describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history // A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them. // entries still name that page, and Back lands on one of them.
@@ -20,6 +20,7 @@ import {
} from "../../lib/history"; } from "../../lib/history";
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import { PLAYER_KEYS } from "../../lib/playerUrlState";
import { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -43,12 +44,14 @@ import {
// //
// The contexts, their value types and the hooks live in navigationContext.ts. // The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every module's own keys cleared — a /** The address bar of `page`'s root: the page (feed implicit), no channel or player over it, and every
* page switch resets to that module's root, and its store mirrors what it holds back in. */ * module's own keys cleared — a page switch resets to that module's root, and its store mirrors what
* it holds back in. */
function pageRootParams(page: Page): Record<string, string | null> { function pageRootParams(page: Page): Record<string, string | null> {
return { return {
page: page === "feed" ? null : page, page: page === "feed" ? null : page,
...closedChannelParams(), ...closedChannelParams(),
...nulls(PLAYER_KEYS),
...nulls(moduleUrlKeys()), ...nulls(moduleUrlKeys()),
}; };
} }
@@ -0,0 +1,40 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("./PlayerModal"));
/** The shell's player, for an address that names one over a page that hosts none of its own (the
* channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null);
useOpenLinkedPlayer(
own,
() => undefined,
(v) => setVideo(v),
);
const onState = useStableCallback((id: string, status: VideoStatus) => {
const write = writeVideoState(qc, id, status);
write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
return write;
});
const close = useStableCallback(() => setVideo(null));
if (!video) return null;
return (
<Suspense fallback={null}>
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
</Suspense>
);
}
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
started.length = 0; started.length = 0;
loaded.length = 0; loaded.length = 0;
seeks.length = 0; seeks.length = 0;
+32 -3
View File
@@ -68,6 +68,7 @@ import {
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen"; import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
import ChannelNameLink from "../video/ChannelNameLink"; import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster"; import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder"; import { useOverlayOrder } from "../../components/ui/overlayOrder";
@@ -222,8 +223,6 @@ export default function PlayerModal({
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
const qc = useQueryClient(); const qc = useQueryClient();
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade(); const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was // Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
// opened with, for its whole session. The live feed refetches and reorders/drops items under // opened with, for its whole session. The live feed refetches and reorders/drops items under
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which // us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
@@ -277,6 +276,25 @@ export default function PlayerModal({
setRelatedOf(active.id); setRelatedOf(active.id);
setRelated(null); setRelated(null);
} }
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
// related video playing in place over it and the panel's tab come back with it. Read once.
const [linked] = useState(() => {
const here = linkedPlayer();
return here && here.watch === video.id ? here : null;
});
const linkedRelated = useQuery({
queryKey: qk.videoRow(linked?.related ?? ""),
queryFn: () => api.videoRow(linked!.related!),
enabled: !!linked?.related,
retry: false,
});
const [relatedAdopted, setRelatedAdopted] = useState(false);
if (!relatedAdopted && linkedRelated.data) {
setRelatedAdopted(true);
// Only over the item it was named with: a step of the queue before it arrived left that one.
if (active.id === linked?.watch && !related)
setRelated({ video: linkedRelated.data, startAt: null });
}
const subject = related?.video ?? active; const subject = related?.video ?? active;
const subjectResume = related const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0) ? (related.startAt ?? related.video.position_seconds ?? 0)
@@ -753,7 +771,7 @@ export default function PlayerModal({
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video // take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the // opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick). // list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description"); const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
const [relatedOpen, setRelatedOpen] = useState(false); const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's // The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again. // tab instead. Left open, it reopened unasked when the window narrowed again.
@@ -762,6 +780,17 @@ export default function PlayerModal({
setPanelTab("related"); setPanelTab("related");
} }
const panelTabs = useTabPanel(); const panelTabs = useTabPanel();
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
// bookmark reopens it as it is; the page's own entry under it never carries them.
useBackToClose(
onClose,
playerParamUpdates({
watch: active.id,
related: related?.video.id ?? null,
panel: wide ? panelTab : "description",
}),
);
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only // Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused. // be refused.
const relatedOn = useMe().related_enabled; const relatedOn = useMe().related_enabled;
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
//
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
// history is module state, and these read the address the closed players of other tests step back over.
//
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { api, type Me, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const started: { videoId: string; start: number | undefined }[] = [];
let playerEvents: { onReady?: () => void } = {};
const fakePlayer = new Proxy(
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
);
window.YT = {
Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer;
} as never,
};
const lastStart = () => started[started.length - 1];
const vid = (id: string, position_seconds = 0): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const related = (id: string, position_seconds = 0) => ({
...vid(id, position_seconds),
followed: false,
});
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
function wideWindow() {
window.matchMedia = ((q: string) => ({
matches: q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
function renderModal(video: Video, queue: Video[]) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
return render(
<QueryClientProvider client={qc}>
<PlayerModal
video={video}
queue={queue}
onClose={() => {}}
onState={() => Promise.resolve()}
/>
</QueryClientProvider>,
);
}
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
async function closeAndLand(unmount: () => void) {
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
unmount();
await act(() => landed);
}
const here = () => new URLSearchParams(window.location.search);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
delete (window as { matchMedia?: unknown }).matchMedia;
started.length = 0;
});
describe("the player's address", () => {
it("names the item playing now in its own entry, a step of the queue too", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(here().get("q")).toBe("talks");
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(here().get("watch")).toBe("B"));
await closeAndLand(r.unmount);
expect(window.location.search).toBe("?q=talks");
});
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/");
const r = renderModal(vid("A", 40), [vid("A", 40)]);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
await waitFor(() => expect(here().get("wtab")).toBe("related"));
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
expect(here().get("watch")).toBe("A");
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(here().has("wrel")).toBe(false));
expect(here().get("watch")).toBe("A");
await closeAndLand(r.unmount);
expect(window.location.search).toBe("");
});
it("wide: a link's related video and panel tab come back over the item it names", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
expect(row).toHaveBeenCalledWith("rel2");
const tab = screen.getByRole("tab", { name: "player.related.title" });
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
expect(here().get("wrel")).toBe("rel2");
await closeAndLand(r.unmount);
// The entry the link opened at named the player; once the player is gone, so is its name.
expect(window.location.search).toBe("");
});
it("wide: a link naming another item brings none of its view", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(row).not.toHaveBeenCalled();
expect(here().has("wrel")).toBe(false);
expect(here().has("wtab")).toBe(false);
expect(lastStart()?.videoId).toBe("A");
await closeAndLand(r.unmount);
});
});
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
// The player itself is not what is under test: it shows what it was opened on.
vi.mock("./PlayerModal", () => ({
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
<p>
playing {video.id} {queue ? "in a list" : "alone"}
</p>
),
}));
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.mocked(notifyActionError).mockClear();
window.history.replaceState(null, "", "/");
});
describe("useOpenLinkedPlayer", () => {
it("opens the item from the host's list once the list is in, and only once", async () => {
window.history.replaceState(null, "", "/?watch=B");
const row = vi.spyOn(api, "videoRow");
const open = vi.fn();
const list = [vid("A"), vid("B")];
const { rerender } = renderHook(
({ ready }) => useOpenLinkedPlayer(ready, (id) => list.find((v) => v.id === id), open),
{ wrapper, initialProps: { ready: false } },
);
expect(open).not.toHaveBeenCalled();
rerender({ ready: true });
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
rerender({ ready: true });
expect(open).toHaveBeenCalledTimes(1);
expect(row).not.toHaveBeenCalled();
});
it("opens an item the list does not hold alone, its row from the catalog", async () => {
window.history.replaceState(null, "", "/?watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
});
it("says so when the catalog no longer has it", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const gone = new Error("not found");
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
);
expect(open).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");
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(open).not.toHaveBeenCalled();
expect(row).not.toHaveBeenCalled();
});
});
describe("the shell's LinkedPlayer", () => {
it("plays the video alone over a page that hosts no player", async () => {
window.history.replaceState(null, "", "/?page=channels&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted={false} />, { wrapper });
expect(await screen.findByText("playing Z alone")).toBeTruthy();
});
it("leaves it to a page that hosts its own", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted />, { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(row).not.toHaveBeenCalled();
expect(screen.queryByText(/playing/)).toBeNull();
});
});
@@ -0,0 +1,39 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { linkedPlayer } from "../../lib/playerUrlState";
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. */
export function useOpenLinkedPlayer(
ready: boolean,
find: (id: string) => Video | undefined,
open: (video: Video, inList: boolean) => void,
): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
// 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 row = useQuery({
queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!),
enabled: ready && linked != null && !listed,
retry: false,
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => notifyActionError(err, t("player.linkedFailed")));
useEffect(() => {
if (done.current) 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;
}, [listed, row.data, row.isError, row.error]);
}
+1
View File
@@ -1,4 +1,5 @@
{ {
"linkedGone": "The file in the address is no longer in this list",
"navLabel": "Files", "navLabel": "Files",
"searchPlaceholder": "Search files…", "searchPlaceholder": "Search files…",
"count_one": "{{count}} item", "count_one": "{{count}} item",
+1
View File
@@ -1,5 +1,6 @@
{ {
"loading": "Loading…", "loading": "Loading…",
"linkedFailed": "The video in the address could not be opened",
"backToOriginal": "Back to the original video", "backToOriginal": "Back to the original video",
"back": "Back", "back": "Back",
"previous": "Previous", "previous": "Previous",
+1
View File
@@ -1,4 +1,5 @@
{ {
"linkedGone": "A címben szereplő fájl már nincs ebben a listában",
"navLabel": "Fájlok", "navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…", "searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem", "count_one": "{{count}} elem",
+1
View File
@@ -1,5 +1,6 @@
{ {
"loading": "Betöltés…", "loading": "Betöltés…",
"linkedFailed": "A címben szereplő videó nem nyitható meg",
"backToOriginal": "Vissza az eredeti videóhoz", "backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza", "back": "Vissza",
"previous": "Előző", "previous": "Előző",
+3
View File
@@ -314,6 +314,9 @@ export const feedApi = {
duration_seconds: Math.floor(durationSeconds), duration_seconds: Math.floor(durationSeconds),
}), }),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`), videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
/** One catalog video as the feed lists it (this user's state with it): what a player opened from an
* address starts from. */
videoRow: (id: string): Promise<Video> => req(`/api/videos/${encodeURIComponent(id)}/row`),
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart. // One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file), takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"), takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
+21 -5
View File
@@ -36,15 +36,31 @@ describe("channelsParamUpdates", () => {
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull(); expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
}); });
it("emits nothing for the all-default state", () => { it("writes the default view in full: this browser remembers one a link must not inherit", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null, cq: null }); expect(channelsParamUpdates(BASE)).toEqual({
ctab: "subscribed",
csort: "none",
cf: "",
cq: null,
});
});
it("a link to the default view opens it over another remembered one", () => {
const params = applyUpdates(channelsParamUpdates(BASE));
const back = channelsParamsToState(params, {
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" },
search: "",
});
expect(back).toEqual(BASE);
}); });
it("clears every key when passed null (off-manager page)", () => { it("clears every key when passed null (off-manager page)", () => {
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null }); expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
}); });
it("emits only non-default keys", () => { it("writes the view picked", () => {
const u = channelsParamUpdates({ const u = channelsParamUpdates({
view: "hidden", view: "hidden",
sort: { key: "subscribers", dir: "desc" }, sort: { key: "subscribers", dir: "desc" },
@@ -57,8 +73,8 @@ describe("channelsParamUpdates", () => {
expect(u.cq).toBe("tom"); expect(u.cq).toBe("tom");
}); });
it("drops empty filter entries so a cleared funnel emits no cf", () => { it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBeNull(); expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
}); });
}); });
+13 -13
View File
@@ -2,10 +2,11 @@
// from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's // from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's
// urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're // urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the // channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are // feed's URL-stripping can't delete them out from under the manager. The tab, sort and filters are
// emitted, so URLs stay clean. The name search (`cq`) is part of the view like the rest: a bookmark of a // always written, their defaults too: a page opened without them takes this browser's remembered view,
// searched list opens searched. // which may not be the one bookmarked. The name search (`cq`) is part of the view like the rest (a
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; // bookmark of a searched list opens searched), but nothing remembers it, so only a term is written.
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
import { type FilterMap } from "./tableColumns"; import { type FilterMap } from "./tableColumns";
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam"; import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
@@ -23,12 +24,11 @@ export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const;
const KEYS = CHANNELS_KEYS; const KEYS = CHANNELS_KEYS;
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"]; const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default // Sort ⇄ `key:dir` (e.g. `subscribers:desc`, `none` unsorted) via the shared codec in columnSort. The
// sort here (fallback null), so any sort emits — no default short-circuit needed. The filter map rides // filter map rides as one JSON value (lib/filterMapParam), an empty one as `cf=`.
// as one JSON value (lib/filterMapParam).
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted /** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear * value, or null to delete it (only the search's, when cleared). Pass `null` to clear
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it * ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
* re-mirrors on return). Feed the result into urlWithParams(). */ * re-mirrors on return). Feed the result into urlWithParams(). */
export function channelsParamUpdates( export function channelsParamUpdates(
@@ -36,9 +36,9 @@ export function channelsParamUpdates(
): Record<string, string | null> { ): Record<string, string | null> {
if (!state) return { ctab: null, csort: null, cf: null, cq: null }; if (!state) return { ctab: null, csort: null, cf: null, cq: null };
return { return {
ctab: state.view && state.view !== "subscribed" ? state.view : null, ctab: state.view || "subscribed",
csort: encodeSortParam(state.sort), csort: encodeSortParamOrNone(state.sort),
cf: encodeFilterMap(state.filters), cf: encodeFilterMap(state.filters) ?? "",
cq: state.search.trim() || null, cq: state.search.trim() || null,
}; };
} }
@@ -55,10 +55,10 @@ export function channelsParamsToState(
base: ChannelsUrlState, base: ChannelsUrlState,
): ChannelsUrlState { ): ChannelsUrlState {
const tab = params.get("ctab"); const tab = params.get("ctab");
const csort = params.get("csort"); const linkedSort = decodeSortParamOrNone(params.get("csort"));
return { return {
view: tab && VIEWS.includes(tab) ? tab : base.view, view: tab && VIEWS.includes(tab) ? tab : base.view,
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort, sort: linkedSort === undefined ? base.sort : linkedSort,
filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters, filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters,
search: params.get("cq") ?? base.search, search: params.get("cq") ?? base.search,
}; };
+16
View File
@@ -53,6 +53,22 @@ export function decodeSortParam(raw: string): SortState {
return parseSortState({ key, dir }); return parseSortState({ key, dir });
} }
// The list's own order (no column sort) as a word of its own, for a page whose sort is remembered (an
// account pref, this browser's view): there the sort is always written, because a page opened without
// one takes the remembered sort, which may not be the one bookmarked.
const UNSORTED = "none";
export function encodeSortParamOrNone(s: SortState): string {
return encodeSortParam(s) ?? UNSORTED;
}
/** The sort an address names; `undefined` when it names none or a malformed one (the remembered one
* applies). */
export function decodeSortParamOrNone(raw: string | null): SortState | undefined {
if (raw == null) return undefined;
return raw === UNSORTED ? null : (decodeSortParam(raw) ?? undefined);
}
/** The header-click cycle: unsorted → ascending → descending → unsorted. */ /** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState { export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" }; if (!current || current.key !== key) return { key, dir: "asc" };
+18 -14
View File
@@ -1,30 +1,34 @@
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in // The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the // modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the
// sort (`?fsort=`). Here, beside the other pages' key lists, so the page's definition can declare them // sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
// without importing its own folder. // other pages' key lists, so the page's definition can declare them without importing its own folder.
export const FILES_FOLDER_KEY = "folder"; export const FILES_FOLDER_KEY = "folder";
export const FILES_ITEM_KEY = "item"; export const FILES_ITEM_KEY = "item";
export const FILES_SEARCH_KEY = "fq"; export const FILES_SEARCH_KEY = "fq";
export const FILES_SORT_KEY = "fsort"; export const FILES_SORT_KEY = "fsort";
// The file open over the list: in the viewer, or in the player. Each dialog names itself in its own
// history entry (ui/Modal's `url`), so Back lands on the list's address without it.
export const FILES_VIEW_KEY = "fview";
export const FILES_PLAY_KEY = "fplay";
export const FILES_KEYS = [ export const FILES_KEYS = [
FILES_FOLDER_KEY, FILES_FOLDER_KEY,
FILES_ITEM_KEY, FILES_ITEM_KEY,
FILES_SEARCH_KEY, FILES_SEARCH_KEY,
FILES_SORT_KEY, FILES_SORT_KEY,
FILES_VIEW_KEY,
FILES_PLAY_KEY,
] as const; ] as const;
// The list's own order (no column sort) has a word of its own: the sort is always written, because a export type FilesOpenFile = { open: "view" | "play"; id: number };
// page opened without one takes the account's remembered sort, which may not be the one bookmarked.
const UNSORTED = "none";
export function encodeFilesSort(sort: SortState): string { /** The file an address opens over the list (a reload, a bookmark), or null. */
return encodeSortParam(sort) ?? UNSORTED; export function openFileFrom(params: URLSearchParams): FilesOpenFile | null {
for (const [open, key] of [
["play", FILES_PLAY_KEY],
["view", FILES_VIEW_KEY],
] as const) {
const id = Number(params.get(key));
if (Number.isInteger(id) && id > 0) return { open, id };
} }
return null;
/** The sort an address names; `undefined` when it names none (the remembered one applies). */
export function decodeFilesSort(raw: string | null): SortState | undefined {
if (raw == null) return undefined;
return raw === UNSORTED ? null : decodeSortParam(raw);
} }
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
//
// An overlay that is part of the view (a player) names itself in its own history entry's address, so a
// reload or a bookmark opens it — and the entry under it never carries those keys, so Back lands on the
// page as it was. Driven through jsdom's real history: its back()/forward() land their popstate a
// task later, like a browser's.
import { afterEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useBackToClose } from "./history";
const settle = () =>
act(async () => {
await Promise.resolve();
});
// Step through the history the way the browser does, and wait for its popstate to land.
const step = (go: () => void) =>
act(async () => {
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
go();
await landed;
await Promise.resolve();
});
const search = () => new URLSearchParams(window.location.search);
/** A player-like overlay whose close really unmounts it, naming `url` in its entry. */
function mountPlayer(initial: Record<string, string | null>) {
const holder: { unmount?: () => void } = {};
const r = renderHook(
(url: Record<string, string | null>) => useBackToClose(() => holder.unmount?.(), url),
{ initialProps: initial },
);
holder.unmount = r.unmount;
return r;
}
afterEach(() => {
window.history.replaceState(null, "", "/");
});
describe("an overlay named in its entry's address", () => {
it("is named in its own entry, and Back lands on the address under it without it", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const depth = window.history.length;
mountPlayer({ watch: "v1", wtab: null });
await settle();
expect(window.history.length).toBe(depth + 1);
expect(search().get("watch")).toBe("v1");
expect(search().get("q")).toBe("music");
await step(() => window.history.back());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
it("follows what it names as it changes", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const r = mountPlayer({ watch: "v1", wtab: null });
await settle();
r.rerender({ watch: "v2", wtab: "related" });
expect(search().get("watch")).toBe("v2");
expect(search().get("wtab")).toBe("related");
r.rerender({ watch: "v2", wtab: null });
expect(search().has("wtab")).toBe(false);
await step(() => r.unmount());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
it("closed by its own control, leaves the entry under it as it was", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const r = mountPlayer({ watch: "v1" });
await settle();
await step(() => r.unmount());
expect(window.location.search).toBe("?q=music");
});
it("an entry that still names it after it closed is cleared once it is the current one", async () => {
// A reload under an open player: the entry the new document starts from names the player.
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&watch=v1");
const r = mountPlayer({ watch: "v1" });
await settle();
await step(() => r.unmount());
expect(search().has("watch")).toBe(false);
// Forward onto the closed player's entry: nothing is open there, so neither is it named.
await step(() => window.history.forward());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
});
+58 -6
View File
@@ -6,7 +6,8 @@
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first. // a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back // - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by // closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath. // its own button pops exactly its entry without tripping the modals underneath. An overlay
// that is part of the view (a player) names itself in its entry's address.
// //
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. // setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -273,8 +274,16 @@ function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the // both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
// last two measured in Chrome). With nothing ever buried, the stack and the history agree by // last two measured in Chrome). With nothing ever buried, the stack and the history agree by
// construction. // construction.
type Overlay = { token: number; cb: () => void }; // `url`: the keys an overlay names in the address of its own entry (and of the entries above it).
type Overlay = { token: number; cb: () => void; url?: OverlayUrl };
type OverlayUrl = Record<string, string | null>;
const overlayStack: Overlay[] = []; const overlayStack: Overlay[] = [];
// Every key an overlay has named in this document: an entry carries those of the overlays it stands
// for and none of the rest. The entry under an overlay is never written with its keys, so Back lands
// on an address without it; and one that still names a closed overlay (the entry a reload under an
// open player started from, a Forward onto an entry whose overlay is gone) is cleared once it is
// the current entry.
const overlayUrlKeys = new Set<string>();
let overlayCounter = 0; let overlayCounter = 0;
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a // A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo). // module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
@@ -298,7 +307,11 @@ function reconcileHistory(): void {
const want = overlayStack.length; const want = overlayStack.length;
while (historyDepth < want && pendingProgrammatic === 0) { while (historyDepth < want && pendingProgrammatic === 0) {
historyDepth++; historyDepth++;
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, ""); window.history.pushState(
{ ...window.history.state, _ov: nextOverlayToken() },
"",
overlayHref(historyDepth),
);
} }
while (historyDepth > want) { while (historyDepth > want) {
historyDepth--; historyDepth--;
@@ -308,12 +321,34 @@ function reconcileHistory(): void {
} else { } else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, ""); window.history.replaceState(st, "", overlayHref(historyDepth));
} }
} }
nameOverlaysHere();
syncWaiters.forEach((cb) => cb()); syncWaiters.forEach((cb) => cb());
} }
// The address of the entry `depth` overlays up: the keys of those overlays, none of the others'.
// Undefined (the address as it is) while no overlay names any.
function overlayHref(depth: number): string | undefined {
if (overlayUrlKeys.size === 0) return undefined;
const updates: OverlayUrl = {};
for (const k of overlayUrlKeys) updates[k] = null;
for (const o of overlayStack.slice(0, depth)) Object.assign(updates, o.url);
return urlWithParams(updates);
}
// Write the current entry's overlay keys, once the history has caught up with the stack (before
// that, the current entry may be one about to be pushed over or stepped back from).
function nameOverlaysHere(): void {
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
const href = overlayHref(historyDepth);
if (href != null && href !== hrefNow())
window.history.replaceState(window.history.state, "", href);
}
const hrefNow = () => window.location.pathname + window.location.search + window.location.hash;
function scheduleReconcile(): void { function scheduleReconcile(): void {
if (reconcileScheduled) return; if (reconcileScheduled) return;
reconcileScheduled = true; reconcileScheduled = true;
@@ -328,6 +363,7 @@ function ensureOverlayListener(): void {
pendingProgrammatic--; // our own back(): consume and ignore pendingProgrammatic--; // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry. // The last one landed: an overlay that opened while it was in flight still owes its entry.
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile(); if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
else nameOverlaysHere();
return; return;
} }
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost // Genuine user Back: the browser already dropped one overlay entry, so close the topmost
@@ -449,15 +485,21 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
} }
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via /** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */ * `onClose` instead of navigating away. Mount the component only while the overlay is open.
export function useBackToClose(onClose: () => void): void { * `url`: what the overlay names in its entry's address (a null value: the key absent), followed as
* it changes; the entry under it never carries those keys. */
export function useBackToClose(onClose: () => void, url?: OverlayUrl): void {
const cb = useEffectEvent(onClose); const cb = useEffectEvent(onClose);
const named = useEffectEvent(() => url);
const entryRef = useRef<Overlay | null>(null);
useEffect(() => { useEffect(() => {
ensureOverlayListener(); ensureOverlayListener();
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() }; const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
entryRef.current = entry;
overlayStack.push(entry); overlayStack.push(entry);
scheduleReconcile(); scheduleReconcile();
return () => { return () => {
entryRef.current = null;
const idx = overlayStack.indexOf(entry); const idx = overlayStack.indexOf(entry);
// idx === -1 means the shared handler already removed us on a real user Back; either way, // idx === -1 means the shared handler already removed us on a real user Back; either way,
// just re-sync history to the (possibly unchanged) overlay count on the next tick. // just re-sync history to the (possibly unchanged) overlay count on the next tick.
@@ -465,6 +507,16 @@ export function useBackToClose(onClose: () => void): void {
scheduleReconcile(); scheduleReconcile();
}; };
}, []); }, []);
// After the effect above (declaration order), so the first run names the entry before it is pushed.
const urlDep = url ? JSON.stringify(url) : "";
useEffect(() => {
const entry = entryRef.current;
const next = named();
if (!entry || !next) return;
entry.url = next;
for (const k of Object.keys(next)) overlayUrlKeys.add(k);
nameOverlaysHere();
}, [urlDep]);
} }
/** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any /** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
+4
View File
@@ -50,4 +50,8 @@ export type ModuleDef = {
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
// one module and none is a shell key (moduleUrl.contract.test). // one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[]; urlKeys: readonly string[];
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
// the shell's player for such an address: the video alone.
playsVideos?: boolean;
}; };
+42
View File
@@ -0,0 +1,42 @@
// The YouTube player's address-bar keys. The player opens over any page (the feed, a channel page, Home,
// a playlist, a channel's watched videos), so its keys are the shell's: no module owns them, a page
// switch clears them, and the page under it keeps its own. The player names them in its own history
// entry (lib/history's `useBackToClose`), so Back lands on the page as it was, without the player.
// The list it plays from is the page's: the feed's filters, the open playlist (`pl`) — already named.
// No position: the player resumes from the server's (`video_states`), which is where it was left.
export const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now
export const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it
export const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description
export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const;
export type PlayerPanelTab = "description" | "related";
export type PlayerUrlState = { watch: string; related: string | null; panel: PlayerPanelTab };
/** The player's keys for its entry. The panel's tab is written only when it is not the default: no
* browser remembers it, so a link without one opens the description as this one would. */
export function playerParamUpdates(state: PlayerUrlState): Record<string, string | null> {
return {
[PLAYER_WATCH_KEY]: state.watch,
[PLAYER_RELATED_KEY]: state.related,
[PLAYER_PANEL_KEY]: state.panel === "related" ? "related" : null,
};
}
/** The player an address names, or null when it names none. */
export function playerFrom(params: URLSearchParams): PlayerUrlState | null {
const watch = params.get(PLAYER_WATCH_KEY);
if (!watch) return null;
return {
watch,
related: params.get(PLAYER_RELATED_KEY) || null,
panel: params.get(PLAYER_PANEL_KEY) === "related" ? "related" : "description",
};
}
/** The player the address names now: read by a host as it mounts (a reload, a bookmark), and by the
* player as it opens. No entry but the player's own carries these keys once the history has caught
* up, so a host mounted later (a page switch back) finds none. */
export function linkedPlayer(): PlayerUrlState | null {
return playerFrom(new URLSearchParams(window.location.search));
}
+18 -7
View File
@@ -48,15 +48,26 @@ const BASE: PlexUrlState = {
}; };
describe("plexParamUpdates", () => { describe("plexParamUpdates", () => {
it("emits nothing for the all-default state", () => { it("writes the default view in full: this account remembers one a link must not inherit", () => {
expect(plexParamUpdates(BASE)).toEqual({ expect(plexParamUpdates(BASE)).toEqual({
pscope: null, pscope: "both",
psort: null, psort: "title:asc",
pshow: null, pshow: "all",
pf: null, pf: "",
}); });
}); });
it("a link to the default view opens it over another remembered one", () => {
const params = applyUpdates(plexParamUpdates(BASE));
const back = plexParamsToState(params, {
scope: "movie",
sort: { key: "year", dir: "desc" },
show: "watched",
filters: RICH_FILTERS,
});
expect(back).toEqual(BASE);
});
it("clears every key when passed null (off-plex page)", () => { it("clears every key when passed null (off-plex page)", () => {
expect(plexParamUpdates(null)).toEqual({ expect(plexParamUpdates(null)).toEqual({
pscope: null, pscope: null,
@@ -66,7 +77,7 @@ describe("plexParamUpdates", () => {
}); });
}); });
it("emits only non-default keys", () => { it("writes the view picked", () => {
const u = plexParamUpdates({ const u = plexParamUpdates({
scope: "movie", scope: "movie",
sort: { key: "year", dir: "desc" }, sort: { key: "year", dir: "desc" },
@@ -76,7 +87,7 @@ describe("plexParamUpdates", () => {
expect(u.pscope).toBe("movie"); expect(u.pscope).toBe("movie");
expect(u.psort).toBe("year:desc"); expect(u.psort).toBe("year:desc");
expect(u.pshow).toBe("in_progress"); expect(u.pshow).toBe("in_progress");
expect(u.pf).toBeNull(); expect(u.pf).toBe("");
}); });
}); });
+12 -19
View File
@@ -3,11 +3,11 @@
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare // urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app // `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced // is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non- // keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Every key is
// default values are emitted, so URLs stay clean. // written on the page, its default too: a page opened without one takes this account's remembered view,
// which may not be the one bookmarked.
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api"; import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
import { PLEX_SORT_FALLBACK } from "./plexSort";
import { parsePlexField, type PlexSearchField } from "./plexSearch"; import { parsePlexField, type PlexSearchField } from "./plexSearch";
// The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of // The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
@@ -30,25 +30,18 @@ export const PLEX_SUB_KEY = "v";
const SCOPES = ["movie", "show", "both"]; const SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"]; const SHOWS = ["all", "unwatched", "in_progress", "watched"];
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`) /** The address-bar delta for the Plex content state: each namespaced key mapped to its value (sort as
// emits nothing. * `key:dir`, the shared columnSort codec; an empty filter set as `pf=`). Pass `null` to clear ALL Plex
function encodeSort(s: SortState): string | null {
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
return encodeSortParam(s);
}
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on * keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
* return). Feed the result into urlWithParams(). */ * return). Feed the result into urlWithParams(). */
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> { export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
if (!state) return { pscope: null, psort: null, pshow: null, pf: null }; if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
const pf = plexFiltersToParams(state.filters).toString(); const pf = plexFiltersToParams(state.filters).toString();
return { return {
pscope: state.scope && state.scope !== "both" ? state.scope : null, pscope: state.scope || "both",
psort: encodeSort(state.sort), psort: encodeSortParamOrNone(state.sort),
pshow: state.show && state.show !== "all" ? state.show : null, pshow: state.show || "all",
pf: pf || null, pf,
}; };
} }
@@ -62,8 +55,8 @@ export function hasPlexParams(params: URLSearchParams): boolean {
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState { export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
const scope = params.get("pscope"); const scope = params.get("pscope");
const show = params.get("pshow"); const show = params.get("pshow");
const psort = params.get("psort"); const linkedSort = decodeSortParamOrNone(params.get("psort"));
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort; const sort = linkedSort === undefined ? base.sort : linkedSort;
return { return {
scope: scope && SCOPES.includes(scope) ? scope : base.scope, scope: scope && SCOPES.includes(scope) ? scope : base.scope,
sort, sort,
+1
View File
@@ -33,6 +33,7 @@ describe("qk — single-id and scalar keys", () => {
it("returns [root, id]", () => { it("returns [root, id]", () => {
expect(qk.channel("c1")).toEqual(["channel", "c1"]); expect(qk.channel("c1")).toEqual(["channel", "c1"]);
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]); expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]);
expect(qk.videoRow("v1")).toEqual(["video-row", "v1"]);
expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]); expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]); expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]); expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
+1
View File
@@ -32,6 +32,7 @@ export const qk = {
ytSearch: (q?: string | null, count?: number) => ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const), q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const, videoDetail: (id: string) => ["video-detail", id] as const,
videoRow: (id: string) => ["video-row", id] as const,
related: (id?: string, region?: string) => related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const), id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it. // This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
+9 -5
View File
@@ -6,6 +6,7 @@
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api"; import { TAG_CATEGORIES, type FeedFilters } from "./api";
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState"; import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
import { PLAYER_KEYS } from "./playerUrlState";
export const FEED_KEYS = [ export const FEED_KEYS = [
"q", "q",
@@ -33,12 +34,14 @@ export const FEED_KEYS = [
const KEYS = FEED_KEYS; const KEYS = FEED_KEYS;
// The keys the shell itself reads, whatever page is on screen: the page, the channel page and its view // The keys the shell itself reads, whatever page is on screen: the page, the channel page and its view
// (it opens over any page, lib/channelPageUrlState), the welcome card's mode and the one-time entry links. No module may declare one of // (it opens over any page, lib/channelPageUrlState), the YouTube player (the same, lib/playerUrlState),
// these (moduleUrl.contract.test), and a page switch never clears them. // the welcome card's mode and the one-time entry links. No module may declare one of these
// (moduleUrl.contract.test); a page switch clears only the channel's and the player's.
export const SHELL_KEYS = [ export const SHELL_KEYS = [
"page", "page",
"chan", "chan",
...CHANNEL_PAGE_KEYS, ...CHANNEL_PAGE_KEYS,
...PLAYER_KEYS,
"m", "m",
"link", "link",
"oauth", "oauth",
@@ -231,12 +234,13 @@ export function urlWithParams(updates: Record<string, string | null>): string {
} }
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an /** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
* entry link not yet read) and the channel — none of the page's keys, and none of another channel's * entry link not yet read) and the channel — none of the page's keys, none of another channel's view
* view (the channel page writes its own, lib/channelPageUrlState). What `openChannel` pushes, and what a * (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
* it). What `openChannel` pushes, and what a
* channel link points at, so a new tab opens the same view. */ * channel link points at, so a new tab opens the same view. */
export function channelUrl(id: string): string { export function channelUrl(id: string): string {
const here = new URLSearchParams(window.location.search); const here = new URLSearchParams(window.location.search);
const viewKeys: readonly string[] = CHANNEL_PAGE_KEYS; const viewKeys: readonly string[] = [...CHANNEL_PAGE_KEYS, ...PLAYER_KEYS];
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const k of SHELL_KEYS) { for (const k of SHELL_KEYS) {
const v = k === "chan" ? id : here.get(k); const v = k === "chan" ? id : here.get(k);
+9
View File
@@ -16,6 +16,7 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState"; import { FILES_KEYS } from "./lib/filesUrlState";
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { playlistViewParamUpdates } from "./lib/playlistsUrlState";
import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; import { SETTINGS_TAB_KEY } from "./lib/settingsTab";
import { CONFIG_TAB_KEY } from "./lib/configTab"; import { CONFIG_TAB_KEY } from "./lib/configTab";
@@ -128,6 +129,14 @@ describe("module URL keys", () => {
); );
}); });
// The player opens over any page too, and names itself in its own history entry.
it("leave the player's keys to the shell", () => {
const shell: readonly string[] = SHELL_KEYS;
expect(PLAYER_KEYS.filter((k) => !shell.includes(k))).toEqual([]);
const written = playerParamUpdates({ watch: "v1", related: "v2", panel: "related" });
expect(Object.keys(written).sort()).toEqual([...PLAYER_KEYS].sort());
});
it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => { it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => {
const declared = [...moduleDef(page).urlKeys].sort(); const declared = [...moduleDef(page).urlKeys].sort();
const writes = WRITES[page]; const writes = WRITES[page];
@@ -106,13 +106,13 @@ describe("ChannelsProvider — shareable URL", () => {
expect(result.current.filters).toEqual({ sync: "needsFull" }); expect(result.current.filters).toEqual({ sync: "needsFull" });
}); });
it("mirrors the tab into the address bar and clears it back to the default", () => { it("mirrors the tab into the address bar, the default one too", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setView("blocked")); act(() => result.current.setView("blocked"));
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked"); expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
// Back to the default tab clears the key (only non-defaults are emitted). // The default tab is named as well: a link without it would open the tab this browser remembers.
act(() => result.current.setView("subscribed")); act(() => result.current.setView("subscribed"));
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false); expect(new URLSearchParams(window.location.search).get("ctab")).toBe("subscribed");
}); });
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => { it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
@@ -53,11 +53,13 @@ vi.mock("../../features/player/PlayerModal", async () => {
return { return {
default: function Player({ default: function Player({
video, video,
queue,
onState, onState,
onWrite, onWrite,
onClose, onClose,
}: { }: {
video: { id: string }; video: { id: string };
queue?: { id: string }[];
onState: (id: string, status: string, v?: unknown) => void; onState: (id: string, status: string, v?: unknown) => void;
onWrite?: (write: Promise<unknown>) => void; onWrite?: (write: Promise<unknown>) => void;
onClose: () => void; onClose: () => void;
@@ -71,6 +73,9 @@ vi.mock("../../features/player/PlayerModal", async () => {
); );
return ( return (
<div> <div>
<p>
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
</p>
<button onClick={() => onState(video.id, "watched")}>mark</button> <button onClick={() => onState(video.id, "watched")}>mark</button>
<button <button
onClick={() => onClick={() =>
@@ -116,6 +121,7 @@ afterEach(() => {
player.watchOnUnmount = false; player.watchOnUnmount = false;
player.flush = null; player.flush = null;
clearAll(); clearAll();
window.history.replaceState(null, "", "/");
}); });
// A cached related list (the player's, kept for the session) holding these videos. // A cached related list (the player's, kept for the session) holding these videos.
@@ -190,6 +196,23 @@ function mount({
return { qc, feed, summary }; return { qc, feed, summary };
} }
// The URL is the view: a reload or a bookmark of the player reopens it once the list is in.
describe("Feed — a player the address names", () => {
it("reopens on its video, the list as its queue", async () => {
window.history.replaceState(null, "", "/?watch=v1");
mount();
expect(await screen.findByText("playing v1 in v1")).toBeTruthy();
});
it("plays a video the loaded list does not hold alone, from the catalog", async () => {
window.history.replaceState(null, "", "/?watch=far");
const row = vi.spyOn(api, "videoRow").mockResolvedValue({ ...video("new"), id: "far" });
mount();
expect(await screen.findByText("playing far in no list")).toBeTruthy();
expect(row).toHaveBeenCalledWith("far");
});
});
describe("Feed — a video marked watched in the player", () => { describe("Feed — a video marked watched in the player", () => {
it("stays out of an Unwatched view when the player writes its position into the cache", async () => { it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
const { qc } = mount(); const { qc } = mount();
+22 -5
View File
@@ -80,6 +80,8 @@ import ViewSwitcher from "../../components/ui/ViewSwitcher";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
import SegmentedToggle from "../../components/ui/SegmentedToggle"; import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -209,10 +211,13 @@ export default function Feed({
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out // A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
// before the server took the reset puts the old position back: written again on its landing. // before the server took the reset puts the old position back: written again on its landing.
const resets = useRef(new Map<string, number>()); const resets = useRef(new Map<string, number>());
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position). `alone`:
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( // opened from an address naming a video this list has not loaded, so it plays without the queue.
null, const [activeVideo, setActiveVideo] = useState<{
); video: Video;
startAt: number | null;
alone?: boolean;
} | null>(null);
const qc = useQueryClient(); const qc = useQueryClient();
const videoMarks = useVideoMarks(); const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused. // Live search turned off on this instance: the empty feed offers no search that would be refused.
@@ -509,6 +514,18 @@ export default function Feed({
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects"). // goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden"); 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.
useOpenLinkedPlayer(
!ytActive && !query.isLoading,
(id) => 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 });
},
);
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale // A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return. // pre-search cache. Drop them so the feed re-fetches fresh on return.
@@ -1006,7 +1023,7 @@ export default function Feed({
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
queue={playerQueue} queue={activeVideo.alone ? undefined : playerQueue}
onClose={closePlayer} onClose={closePlayer}
onState={onState} onState={onState}
onWrite={trackSessionWrite} onWrite={trackSessionWrite}
+1
View File
@@ -17,6 +17,7 @@ export const feedModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore, Store: FeedStore,
urlKeys: FEED_KEYS, urlKeys: FEED_KEYS,
playsVideos: true,
}; };
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the // The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
@@ -14,6 +14,7 @@ import { formatBytes } from "../../lib/format";
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { FILES_VIEW_KEY } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle"; import { jobTitle } from "../../features/downloads/downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { ContextMenu } from "../../components/ui/ContextMenu"; import { ContextMenu } from "../../components/ui/ContextMenu";
@@ -128,6 +129,7 @@ export default function FilePreview({
title={<span className="block truncate">{title}</span>} title={<span className="block truncate">{title}</span>}
onClose={onClose} onClose={onClose}
maxWidth="max-w-6xl" maxWidth="max-w-6xl"
url={{ [FILES_VIEW_KEY]: String(file.id) }}
headerActions={ headerActions={
<> <>
<IconButton <IconButton
@@ -745,6 +745,51 @@ describe("Files · the viewer", () => {
}); });
}); });
// The URL is the view: the file open over the list is named in the address, so a reload or a bookmark
// reopens it once the list is in.
describe("Files · a file the address names", () => {
const doc = (id: number) =>
file(id, {
job_kind: "upload",
display_name: `Doc ${id}`,
asset: { media_kind: "file", preview: "pdf", container: "pdf", size_bytes: 10 },
} as never);
const here = () => new URLSearchParams(window.location.search);
it("reopens the viewer on it, and the viewer names the file it turns to", async () => {
window.history.replaceState(null, "", "/?page=files&fview=2");
mocks.files = [doc(2), doc(3)];
mocks.folders = [YT];
mount();
const dialog = await screen.findByRole("dialog");
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
await waitFor(() => expect(here().get("fview")).toBe("2"));
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() => expect(here().get("fview")).toBe("3"));
});
it("reopens the player on a media file", async () => {
window.history.replaceState(null, "", "/?page=files&fplay=1");
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
expect(await screen.findByText("playing 1")).toBeTruthy();
});
it("says so when the list no longer holds it", async () => {
window.history.replaceState(null, "", "/?page=files&fview=99");
mocks.files = [doc(2)];
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: "files.linkedGone" }),
),
);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("Files · the view", () => { describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => { it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)]; mocks.files = [file(2)];
+26 -4
View File
@@ -10,7 +10,12 @@ import {
type FileSort, type FileSort,
type LibraryFile, type LibraryFile,
} from "../../lib/api"; } from "../../lib/api";
import { type SortState, sortRows } from "../../lib/columnSort"; import {
decodeSortParamOrNone,
encodeSortParamOrNone,
type SortState,
sortRows,
} from "../../lib/columnSort";
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format"; import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
import { focusNavItem } from "../../lib/listNav"; import { focusNavItem } from "../../lib/listNav";
@@ -20,7 +25,7 @@ import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { decodeFilesSort, encodeFilesSort, FILES_SORT_KEY } from "../../lib/filesUrlState"; import { FILES_SORT_KEY, openFileFrom } from "../../lib/filesUrlState";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { JobThumb } from "../../features/downloads/downloadRow"; import { JobThumb } from "../../features/downloads/downloadRow";
@@ -213,14 +218,14 @@ export default function Files() {
// A sort the address names wins until one is picked here (which is then the account's again), and // A sort the address names wins until one is picked here (which is then the account's again), and
// the address names the sort on screen. // the address names the sort on screen.
const [linkedSort, setLinkedSort] = useState(() => const [linkedSort, setLinkedSort] = useState(() =>
decodeFilesSort(new URLSearchParams(window.location.search).get(FILES_SORT_KEY)), decodeSortParamOrNone(new URLSearchParams(window.location.search).get(FILES_SORT_KEY)),
); );
const sort = linkedSort === undefined ? prefSort : linkedSort; const sort = linkedSort === undefined ? prefSort : linkedSort;
const setSort = (next: SortState) => { const setSort = (next: SortState) => {
setLinkedSort(undefined); setLinkedSort(undefined);
setPrefSort(next); setPrefSort(next);
}; };
useUrlMirror({ [FILES_SORT_KEY]: encodeFilesSort(sort) }); useUrlMirror({ [FILES_SORT_KEY]: encodeSortParamOrNone(sort) });
const params = listParams(shown); const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. // 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 }); const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
@@ -273,6 +278,23 @@ export default function Files() {
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart }); const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
// The file open in the viewer (one that is not media), by id: the list under it keeps re-reading. // The file open in the viewer (one that is not media), by id: the list under it keeps re-reading.
const [viewing, setViewing] = useState<number | null>(null); 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.
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) notify({ level: "warning", message: t("files.linkedGone") });
else if (linkedFile?.open === "play") play(file);
else setViewing(file.id);
});
useEffect(() => {
if (linkedDone.current || !listIn) return;
linkedDone.current = true;
openLinked(linkedRow);
}, [listIn, linkedRow]);
const actions = useFileActions({ const actions = useFileActions({
folders: foldersQ.data ?? null, folders: foldersQ.data ?? null,
+8 -1
View File
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api"; import { api, type DownloadJob } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { FILES_PLAY_KEY } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle"; import { jobTitle } from "../../features/downloads/downloadTitle";
import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../../lib/mediaLog"; import { useMediaLogRef } from "../../lib/mediaLog";
@@ -91,7 +92,13 @@ export default function LibraryPlayer({
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint); useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
return ( return (
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl"> <Modal
initialFocus="player"
title={title}
onClose={onClose}
maxWidth="max-w-4xl"
url={{ [FILES_PLAY_KEY]: String(job.id) }}
>
{isAudio ? ( {isAudio ? (
<div className="flex flex-col items-center gap-4 py-2"> <div className="flex flex-col items-center gap-4 py-2">
{poster ? ( {poster ? (
@@ -45,9 +45,11 @@ vi.mock("react-i18next", async (importOriginal) => {
const discover = vi.fn(); const discover = vi.fn();
const discoverPick = vi.fn(); const discoverPick = vi.fn();
const videoRow = vi.fn();
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { api: {
discover: (...a: unknown[]) => discover(...a), discover: (...a: unknown[]) => discover(...a),
videoRow: (...a: unknown[]) => videoRow(...a),
discoverPick: (...a: unknown[]) => discoverPick(...a), discoverPick: (...a: unknown[]) => discoverPick(...a),
setState: () => Promise.resolve(), setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(), watchLaterAdd: () => Promise.resolve(),
@@ -70,6 +72,11 @@ vi.mock("../plex/plexContext", () => ({
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }), usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
})); }));
// The player itself is pinned in its own tests: here, what Home opens it on.
vi.mock("../../features/player/PlayerModal", () => ({
default: ({ video }: { video: { id: string } }) => <p>playing {video.id}</p>,
}));
import Home from "./Home"; import Home from "./Home";
import { requestSurprise, takeSurprise } from "../../lib/homeIntent"; import { requestSurprise, takeSurprise } from "../../lib/homeIntent";
@@ -179,6 +186,17 @@ beforeEach(() => {
}); });
afterEach(cleanup); afterEach(cleanup);
describe("Home — a player the address names", () => {
it("reopens on its video, alone as Home plays it", async () => {
window.history.replaceState(null, "", "/?page=home&watch=v9");
videoRow.mockResolvedValue({ id: "v9", title: "Nine" });
mount();
expect(await screen.findByText("playing v9")).toBeTruthy();
expect(videoRow).toHaveBeenCalledWith("v9");
window.history.replaceState(null, "", "/");
});
});
describe("Home", () => { describe("Home", () => {
it("labels the hero from the server's tier, not from the card", async () => { it("labels the hero from the server's tier, not from the card", async () => {
// The same card shape is served under a `surprise` tier: a client that inferred "where you left // The same card shape is served under a `surprise` tier: a client that inferred "where you left
+7
View File
@@ -78,6 +78,7 @@ import {
mediaTitleClass, mediaTitleClass,
} from "../../components/ui/mediaCardStyles"; } from "../../components/ui/mediaCardStyles";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
// The Home surface — "Mit nézzek most?". // The Home surface — "Mit nézzek most?".
// //
@@ -729,6 +730,12 @@ export default function Home() {
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, ""); window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
}, [budget, seed, press, seen]); }, [budget, seed, press, seen]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null); const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
// A reload or a bookmark of the player reopens it: the video alone, as Home plays it.
useOpenLinkedPlayer(
true,
() => undefined,
(video) => setPlayer({ video, startAt: null }),
);
// A Plex card's title is in the reader's language, so the answer is cached per language. // A Plex card's title is in the reader's language, so the answer is cached per language.
const lang = plexLang(i18n.language); const lang = plexLang(i18n.language);
+1
View File
@@ -14,4 +14,5 @@ export const homeModule: ModuleDef = {
// Nothing of its own: the question is an answer, not a setting — the same question draws other // Nothing of its own: the question is an answer, not a setting — the same question draws other
// cards once what you have watched moves on. // cards once what you have watched moves on.
urlKeys: [], urlKeys: [],
playsVideos: true,
}; };
@@ -18,6 +18,8 @@ const h = vi.hoisted(() => ({
mounts: 0, mounts: 0,
player: null as null | { player: null as null | {
video: { id: string; position_seconds: number }; video: { id: string; position_seconds: number };
queue?: { id: string }[];
startIndex?: number;
onClose: () => void; onClose: () => void;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onRemoveFromList?: (v: unknown) => Promise<boolean>; onRemoveFromList?: (v: unknown) => Promise<boolean>;
@@ -187,6 +189,27 @@ afterEach(() => {
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
}); });
// The URL is the view: a reload or a bookmark of the player reopens it once the playlist is in.
describe("Playlists — a player the address names", () => {
it("reopens on its video, the playlist's view as the queue", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=B");
mount({ 1: [vid("A", 0), vid("B", 0)] });
await waitFor(() => expect(h.player?.video.id).toBe("B"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["A", "B"]);
expect(h.player!.startIndex).toBe(1);
expect(h.player!.onRemoveFromList).toBeDefined();
});
it("plays a video the view does not show alone, with nothing to remove it from", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
mount({ 1: [vid("A", 0)] });
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
expect(h.player!.onRemoveFromList).toBeUndefined();
});
});
describe("Playlists — the row the player is launched with", () => { describe("Playlists — the row the player is launched with", () => {
it("carries the position the player just stored, not the one the list first loaded with", async () => { it("carries the position the player just stored, not the one the list first loaded with", async () => {
// The page renders from its own undoable copy of the list, re-seeded only when the SET of ids // The page renders from its own undoable copy of the list, re-seeded only when the SET of ids
@@ -50,6 +50,7 @@ import {
} from "../../lib/playlistsUrlState"; } from "../../lib/playlistsUrlState";
import { formatRuntime } from "../../lib/format"; import { formatRuntime } from "../../lib/format";
import { applyView, sameOrder } from "../../lib/playlistView"; import { applyView, sameOrder } from "../../lib/playlistView";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
@@ -580,6 +581,17 @@ function PlaylistDetail() {
shownRef.current = shown; shownRef.current = shown;
removeRef.current = removeItem; removeRef.current = removeItem;
}); });
// 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.
useOpenLinkedPlayer(
detail != null && items.length > 0,
(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 });
},
);
async function moveItemTo(video: Video, target: Playlist) { async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return; if (selectedId == null) return;
+1
View File
@@ -19,4 +19,5 @@ export const playlistsModule: ModuleDef = {
Store: PlaylistsProvider, Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit). // The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: PLAYLISTS_KEYS, urlKeys: PLAYLISTS_KEYS,
playsVideos: true,
}; };
+4
View File
@@ -29,6 +29,7 @@ import KeyboardHelp from "../components/ui/KeyboardHelp";
import AppCommandPalette from "./AppCommandPalette"; import AppCommandPalette from "./AppCommandPalette";
import DownloadIntake from "../features/downloads/DownloadIntake"; import DownloadIntake from "../features/downloads/DownloadIntake";
import DownloadTray from "../features/downloads/DownloadTray"; import DownloadTray from "../features/downloads/DownloadTray";
import { LinkedPlayer } from "../features/player/LinkedPlayer";
import ErrorDialog from "./ErrorDialog"; import ErrorDialog from "./ErrorDialog";
import VersionBanner from "./VersionBanner"; import VersionBanner from "./VersionBanner";
import VerifyBanner from "./VerifyBanner"; import VerifyBanner from "./VerifyBanner";
@@ -187,6 +188,9 @@ export default function App() {
<AppCommandPalette /> <AppCommandPalette />
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */} {/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
<DownloadIntake /> <DownloadIntake />
{/* The player an address names over a page that plays none itself (a channel page plays its own,
through its feed); decided once, at boot. */}
<LinkedPlayer hosted={!!channelView || !!moduleDef(page).playsVideos} />
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */} {/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
{me && <DebugRecorder />} {me && <DebugRecorder />}
<Suspense fallback={null}> <Suspense fallback={null}>