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