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:
@@ -249,6 +249,21 @@ def set_video_progress(
|
||||
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}")
|
||||
def get_video_detail(
|
||||
video_id: str,
|
||||
|
||||
@@ -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"
|
||||
@@ -65,6 +65,7 @@ export default function Modal({
|
||||
placement = "center",
|
||||
initialFocus,
|
||||
headerActions,
|
||||
url,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
initialFocus: ModalFocus;
|
||||
@@ -81,10 +82,14 @@ export default function Modal({
|
||||
placement?: "center" | "drawer" | "drawer-start" | "top";
|
||||
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
|
||||
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();
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { urlPage } from "./urlPage";
|
||||
import { channelUrl } from "../../lib/urlState";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
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", () => {
|
||||
// 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.
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../lib/history";
|
||||
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
|
||||
import { PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import {
|
||||
channelViewFrom,
|
||||
loadInitialPage,
|
||||
@@ -43,12 +44,14 @@ import {
|
||||
//
|
||||
// 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
|
||||
* page switch resets to that module's root, and its store mirrors what it holds back in. */
|
||||
/** The address bar of `page`'s root: the page (feed implicit), no channel or player over it, and every
|
||||
* 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> {
|
||||
return {
|
||||
page: page === "feed" ? null : page,
|
||||
...closedChannelParams(),
|
||||
...nulls(PLAYER_KEYS),
|
||||
...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(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
started.length = 0;
|
||||
loaded.length = 0;
|
||||
seeks.length = 0;
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
|
||||
import ChannelNameLink from "../video/ChannelNameLink";
|
||||
import Toaster from "../../components/ui/Toaster";
|
||||
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
||||
@@ -222,8 +223,6 @@ export default function PlayerModal({
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
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
|
||||
// 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
|
||||
@@ -277,6 +276,25 @@ export default function PlayerModal({
|
||||
setRelatedOf(active.id);
|
||||
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 subjectResume = related
|
||||
? (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
|
||||
// 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).
|
||||
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
|
||||
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
|
||||
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
|
||||
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
||||
@@ -762,6 +780,17 @@ export default function PlayerModal({
|
||||
setPanelTab("related");
|
||||
}
|
||||
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
|
||||
// be refused.
|
||||
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,4 +1,5 @@
|
||||
{
|
||||
"linkedGone": "The file in the address is no longer in this list",
|
||||
"navLabel": "Files",
|
||||
"searchPlaceholder": "Search files…",
|
||||
"count_one": "{{count}} item",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"loading": "Loading…",
|
||||
"linkedFailed": "The video in the address could not be opened",
|
||||
"backToOriginal": "Back to the original video",
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"linkedGone": "A címben szereplő fájl már nincs ebben a listában",
|
||||
"navLabel": "Fájlok",
|
||||
"searchPlaceholder": "Keresés a fájlok között…",
|
||||
"count_one": "{{count}} elem",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"loading": "Betöltés…",
|
||||
"linkedFailed": "A címben szereplő videó nem nyitható meg",
|
||||
"backToOriginal": "Vissza az eredeti videóhoz",
|
||||
"back": "Vissza",
|
||||
"previous": "Előző",
|
||||
|
||||
@@ -314,6 +314,9 @@ export const feedApi = {
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
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.
|
||||
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
|
||||
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
|
||||
|
||||
@@ -36,15 +36,31 @@ describe("channelsParamUpdates", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
|
||||
});
|
||||
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
|
||||
it("writes the default view in full: this browser remembers one a link must not inherit", () => {
|
||||
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)", () => {
|
||||
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({
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
@@ -57,8 +73,8 @@ describe("channelsParamUpdates", () => {
|
||||
expect(u.cq).toBe("tom");
|
||||
});
|
||||
|
||||
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBeNull();
|
||||
it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// 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
|
||||
// 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
|
||||
// emitted, so URLs stay clean. The name search (`cq`) is part of the view like the rest: a bookmark of a
|
||||
// searched list opens searched.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
// feed's URL-stripping can't delete them out from under the manager. The tab, sort and filters are
|
||||
// always written, their defaults too: a page opened without them takes this browser's remembered view,
|
||||
// which may not be the one bookmarked. The name search (`cq`) is part of the view like the rest (a
|
||||
// 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 { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
|
||||
|
||||
@@ -23,12 +24,11 @@ export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const;
|
||||
const KEYS = CHANNELS_KEYS;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed. The filter map rides
|
||||
// as one JSON value (lib/filterMapParam).
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`, `none` unsorted) via the shared codec in columnSort. The
|
||||
// filter map rides as one JSON value (lib/filterMapParam), an empty one as `cf=`.
|
||||
|
||||
/** 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
|
||||
* re-mirrors on return). Feed the result into urlWithParams(). */
|
||||
export function channelsParamUpdates(
|
||||
@@ -36,9 +36,9 @@ export function channelsParamUpdates(
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { ctab: null, csort: null, cf: null, cq: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilterMap(state.filters),
|
||||
ctab: state.view || "subscribed",
|
||||
csort: encodeSortParamOrNone(state.sort),
|
||||
cf: encodeFilterMap(state.filters) ?? "",
|
||||
cq: state.search.trim() || null,
|
||||
};
|
||||
}
|
||||
@@ -55,10 +55,10 @@ export function channelsParamsToState(
|
||||
base: ChannelsUrlState,
|
||||
): ChannelsUrlState {
|
||||
const tab = params.get("ctab");
|
||||
const csort = params.get("csort");
|
||||
const linkedSort = decodeSortParamOrNone(params.get("csort"));
|
||||
return {
|
||||
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,
|
||||
search: params.get("cq") ?? base.search,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,22 @@ export function decodeSortParam(raw: string): SortState {
|
||||
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. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// without importing its own folder.
|
||||
// sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
|
||||
// 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_ITEM_KEY = "item";
|
||||
export const FILES_SEARCH_KEY = "fq";
|
||||
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 = [
|
||||
FILES_FOLDER_KEY,
|
||||
FILES_ITEM_KEY,
|
||||
FILES_SEARCH_KEY,
|
||||
FILES_SORT_KEY,
|
||||
FILES_VIEW_KEY,
|
||||
FILES_PLAY_KEY,
|
||||
] as const;
|
||||
|
||||
// The list's own order (no column sort) has a word of its own: the sort is always written, because a
|
||||
// page opened without one takes the account's remembered sort, which may not be the one bookmarked.
|
||||
const UNSORTED = "none";
|
||||
export type FilesOpenFile = { open: "view" | "play"; id: number };
|
||||
|
||||
export function encodeFilesSort(sort: SortState): string {
|
||||
return encodeSortParam(sort) ?? UNSORTED;
|
||||
/** The file an address opens over the list (a reload, a bookmark), or null. */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
// 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
|
||||
// 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.
|
||||
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
|
||||
// last two measured in Chrome). With nothing ever buried, the stack and the history agree by
|
||||
// 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[] = [];
|
||||
// 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;
|
||||
// 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).
|
||||
@@ -298,7 +307,11 @@ function reconcileHistory(): void {
|
||||
const want = overlayStack.length;
|
||||
while (historyDepth < want && pendingProgrammatic === 0) {
|
||||
historyDepth++;
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _ov: nextOverlayToken() },
|
||||
"",
|
||||
overlayHref(historyDepth),
|
||||
);
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
@@ -308,12 +321,34 @@ function reconcileHistory(): void {
|
||||
} else {
|
||||
// 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>;
|
||||
window.history.replaceState(st, "");
|
||||
window.history.replaceState(st, "", overlayHref(historyDepth));
|
||||
}
|
||||
}
|
||||
nameOverlaysHere();
|
||||
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 {
|
||||
if (reconcileScheduled) return;
|
||||
reconcileScheduled = true;
|
||||
@@ -328,6 +363,7 @@ function ensureOverlayListener(): void {
|
||||
pendingProgrammatic--; // our own back(): consume and ignore
|
||||
// The last one landed: an overlay that opened while it was in flight still owes its entry.
|
||||
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
|
||||
else nameOverlaysHere();
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||
export function useBackToClose(onClose: () => void): void {
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open.
|
||||
* `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 named = useEffectEvent(() => url);
|
||||
const entryRef = useRef<Overlay | null>(null);
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
entryRef.current = entry;
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
entryRef.current = null;
|
||||
const idx = overlayStack.indexOf(entry);
|
||||
// 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.
|
||||
@@ -465,6 +507,16 @@ export function useBackToClose(onClose: () => void): void {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// one module and none is a shell key (moduleUrl.contract.test).
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -48,15 +48,26 @@ const BASE: PlexUrlState = {
|
||||
};
|
||||
|
||||
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({
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
pscope: "both",
|
||||
psort: "title:asc",
|
||||
pshow: "all",
|
||||
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)", () => {
|
||||
expect(plexParamUpdates(null)).toEqual({
|
||||
pscope: null,
|
||||
@@ -66,7 +77,7 @@ describe("plexParamUpdates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
it("writes the view picked", () => {
|
||||
const u = plexParamUpdates({
|
||||
scope: "movie",
|
||||
sort: { key: "year", dir: "desc" },
|
||||
@@ -76,7 +87,7 @@ describe("plexParamUpdates", () => {
|
||||
expect(u.pscope).toBe("movie");
|
||||
expect(u.psort).toBe("year:desc");
|
||||
expect(u.pshow).toBe("in_progress");
|
||||
expect(u.pf).toBeNull();
|
||||
expect(u.pf).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
// 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
|
||||
// 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-
|
||||
// default values are emitted, so URLs stay clean.
|
||||
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Every key is
|
||||
// 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 { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "./plexSearch";
|
||||
|
||||
// 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 SHOWS = ["all", "unwatched", "in_progress", "watched"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`)
|
||||
// emits nothing.
|
||||
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
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its value (sort as
|
||||
* `key:dir`, the shared columnSort codec; an empty filter set as `pf=`). 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
|
||||
* return). Feed the result into urlWithParams(). */
|
||||
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
|
||||
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
|
||||
const pf = plexFiltersToParams(state.filters).toString();
|
||||
return {
|
||||
pscope: state.scope && state.scope !== "both" ? state.scope : null,
|
||||
psort: encodeSort(state.sort),
|
||||
pshow: state.show && state.show !== "all" ? state.show : null,
|
||||
pf: pf || null,
|
||||
pscope: state.scope || "both",
|
||||
psort: encodeSortParamOrNone(state.sort),
|
||||
pshow: state.show || "all",
|
||||
pf,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,8 +55,8 @@ export function hasPlexParams(params: URLSearchParams): boolean {
|
||||
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
|
||||
const scope = params.get("pscope");
|
||||
const show = params.get("pshow");
|
||||
const psort = params.get("psort");
|
||||
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
|
||||
const linkedSort = decodeSortParamOrNone(params.get("psort"));
|
||||
const sort = linkedSort === undefined ? base.sort : linkedSort;
|
||||
return {
|
||||
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
|
||||
sort,
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("qk — single-id and scalar keys", () => {
|
||||
it("returns [root, id]", () => {
|
||||
expect(qk.channel("c1")).toEqual(["channel", "c1"]);
|
||||
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.plexItem("i1")).toEqual(["plex-item", "i1"]);
|
||||
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
|
||||
|
||||
@@ -32,6 +32,7 @@ export const qk = {
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
videoRow: (id: string) => ["video-row", id] as const,
|
||||
related: (id?: string, region?: string) =>
|
||||
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
|
||||
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
||||
import { TAG_CATEGORIES, type FeedFilters } from "./api";
|
||||
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
|
||||
import { PLAYER_KEYS } from "./playerUrlState";
|
||||
|
||||
export const FEED_KEYS = [
|
||||
"q",
|
||||
@@ -33,12 +34,14 @@ export const 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
|
||||
// (it opens over any page, lib/channelPageUrlState), the welcome card's mode and the one-time entry links. No module may declare one of
|
||||
// these (moduleUrl.contract.test), and a page switch never clears them.
|
||||
// (it opens over any page, lib/channelPageUrlState), the YouTube player (the same, lib/playerUrlState),
|
||||
// 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 = [
|
||||
"page",
|
||||
"chan",
|
||||
...CHANNEL_PAGE_KEYS,
|
||||
...PLAYER_KEYS,
|
||||
"m",
|
||||
"link",
|
||||
"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
|
||||
* entry link not yet read) and the channel — none of the page's keys, and none of another channel's
|
||||
* view (the channel page writes its own, lib/channelPageUrlState). What `openChannel` pushes, and what a
|
||||
* entry link not yet read) and the channel — none of the page's keys, none of another channel's view
|
||||
* (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. */
|
||||
export function channelUrl(id: string): string {
|
||||
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();
|
||||
for (const k of SHELL_KEYS) {
|
||||
const v = k === "chan" ? id : here.get(k);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
|
||||
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
|
||||
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
|
||||
import { FILES_KEYS } from "./lib/filesUrlState";
|
||||
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
|
||||
import { playlistViewParamUpdates } from "./lib/playlistsUrlState";
|
||||
import { SETTINGS_TAB_KEY } from "./lib/settingsTab";
|
||||
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) => {
|
||||
const declared = [...moduleDef(page).urlKeys].sort();
|
||||
const writes = WRITES[page];
|
||||
|
||||
@@ -106,13 +106,13 @@ describe("ChannelsProvider — shareable URL", () => {
|
||||
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();
|
||||
act(() => result.current.setView("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"));
|
||||
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", () => {
|
||||
|
||||
@@ -53,11 +53,13 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
return {
|
||||
default: function Player({
|
||||
video,
|
||||
queue,
|
||||
onState,
|
||||
onWrite,
|
||||
onClose,
|
||||
}: {
|
||||
video: { id: string };
|
||||
queue?: { id: string }[];
|
||||
onState: (id: string, status: string, v?: unknown) => void;
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
onClose: () => void;
|
||||
@@ -71,6 +73,9 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
);
|
||||
return (
|
||||
<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={() =>
|
||||
@@ -116,6 +121,7 @@ afterEach(() => {
|
||||
player.watchOnUnmount = false;
|
||||
player.flush = null;
|
||||
clearAll();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
// A cached related list (the player's, kept for the session) holding these videos.
|
||||
@@ -190,6 +196,23 @@ function mount({
|
||||
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", () => {
|
||||
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
|
||||
const { qc } = mount();
|
||||
|
||||
@@ -80,6 +80,8 @@ import ViewSwitcher from "../../components/ui/ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
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.
|
||||
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
|
||||
// before the server took the reset puts the old position back: written again on its landing.
|
||||
const resets = useRef(new Map<string, number>());
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null,
|
||||
);
|
||||
// The open player: which video and where to start (null = resume from saved position). `alone`:
|
||||
// opened from an address naming a video this list has not loaded, so it plays without the queue.
|
||||
const [activeVideo, setActiveVideo] = useState<{
|
||||
video: Video;
|
||||
startAt: number | null;
|
||||
alone?: boolean;
|
||||
} | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// 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
|
||||
// goes hidden still drops out ("visible affects").
|
||||
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
|
||||
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
|
||||
// (the feed's filters are in the address under it). Not over a live search: that is not in the
|
||||
// address, and a reload leaves it.
|
||||
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
|
||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||
@@ -1006,7 +1023,7 @@ export default function Feed({
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={playerQueue}
|
||||
queue={activeVideo.alone ? undefined : playerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const feedModule: ModuleDef = {
|
||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||
Store: FeedStore,
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatBytes } from "../../lib/format";
|
||||
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { FILES_VIEW_KEY } from "../../lib/filesUrlState";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
@@ -128,6 +129,7 @@ export default function FilePreview({
|
||||
title={<span className="block truncate">{title}</span>}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-6xl"
|
||||
url={{ [FILES_VIEW_KEY]: String(file.id) }}
|
||||
headerActions={
|
||||
<>
|
||||
<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", () => {
|
||||
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
|
||||
mocks.files = [file(2)];
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
type FileSort,
|
||||
type LibraryFile,
|
||||
} 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 { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
@@ -20,7 +25,7 @@ import { LS, ownKey } from "../../lib/storage";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
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 { usePref } from "../../lib/usePref";
|
||||
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
|
||||
// the address names the sort on screen.
|
||||
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 setSort = (next: SortState) => {
|
||||
setLinkedSort(undefined);
|
||||
setPrefSort(next);
|
||||
};
|
||||
useUrlMirror({ [FILES_SORT_KEY]: encodeFilesSort(sort) });
|
||||
useUrlMirror({ [FILES_SORT_KEY]: encodeSortParamOrNone(sort) });
|
||||
const params = listParams(shown);
|
||||
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
|
||||
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
|
||||
@@ -273,6 +278,23 @@ export default function Files() {
|
||||
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.
|
||||
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({
|
||||
folders: foldersQ.data ?? null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { api, type DownloadJob } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { FILES_PLAY_KEY } from "../../lib/filesUrlState";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay";
|
||||
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);
|
||||
|
||||
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 ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
|
||||
@@ -45,9 +45,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
const discover = vi.fn();
|
||||
const discoverPick = vi.fn();
|
||||
const videoRow = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
discover: (...a: unknown[]) => discover(...a),
|
||||
videoRow: (...a: unknown[]) => videoRow(...a),
|
||||
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
||||
setState: () => Promise.resolve(),
|
||||
watchLaterAdd: () => Promise.resolve(),
|
||||
@@ -70,6 +72,11 @@ vi.mock("../plex/plexContext", () => ({
|
||||
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 { requestSurprise, takeSurprise } from "../../lib/homeIntent";
|
||||
|
||||
@@ -179,6 +186,17 @@ beforeEach(() => {
|
||||
});
|
||||
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", () => {
|
||||
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
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
mediaTitleClass,
|
||||
} from "../../components/ui/mediaCardStyles";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
||||
|
||||
// The Home surface — "Mit nézzek most?".
|
||||
//
|
||||
@@ -729,6 +730,12 @@ export default function Home() {
|
||||
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
|
||||
}, [budget, seed, press, seen]);
|
||||
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.
|
||||
const lang = plexLang(i18n.language);
|
||||
|
||||
@@ -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
|
||||
// cards once what you have watched moves on.
|
||||
urlKeys: [],
|
||||
playsVideos: true,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ const h = vi.hoisted(() => ({
|
||||
mounts: 0,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
queue?: { id: string }[];
|
||||
startIndex?: number;
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
onRemoveFromList?: (v: unknown) => Promise<boolean>;
|
||||
@@ -187,6 +189,27 @@ afterEach(() => {
|
||||
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", () => {
|
||||
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
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "../../lib/playlistsUrlState";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { applyView, sameOrder } from "../../lib/playlistView";
|
||||
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
||||
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
@@ -580,6 +581,17 @@ function PlaylistDetail() {
|
||||
shownRef.current = shown;
|
||||
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) {
|
||||
if (selectedId == null) return;
|
||||
|
||||
@@ -19,4 +19,5 @@ export const playlistsModule: ModuleDef = {
|
||||
Store: PlaylistsProvider,
|
||||
// The playlist shown (PlaylistsProvider, playlistsInit).
|
||||
urlKeys: PLAYLISTS_KEYS,
|
||||
playsVideos: true,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import KeyboardHelp from "../components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./AppCommandPalette";
|
||||
import DownloadIntake from "../features/downloads/DownloadIntake";
|
||||
import DownloadTray from "../features/downloads/DownloadTray";
|
||||
import { LinkedPlayer } from "../features/player/LinkedPlayer";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
import VersionBanner from "./VersionBanner";
|
||||
import VerifyBanner from "./VerifyBanner";
|
||||
@@ -187,6 +188,9 @@ export default function App() {
|
||||
<AppCommandPalette />
|
||||
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
|
||||
<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. */}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
|
||||
Reference in New Issue
Block a user