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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user