diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 60b68538..f36294dd 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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, diff --git a/backend/tests/test_video_row.py b/backend/tests/test_video_row.py new file mode 100644 index 00000000..a851442c --- /dev/null +++ b/backend/tests/test_video_row.py @@ -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" diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 4d3d5a94..86964f22 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -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; }) { 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 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. diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index aa74bceb..7d2ecdd8 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -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. diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index e9200276..80d0fd23 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -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 { return { page: page === "feed" ? null : page, ...closedChannelParams(), + ...nulls(PLAYER_KEYS), ...nulls(moduleUrlKeys()), }; } diff --git a/frontend/src/features/player/LinkedPlayer.tsx b/frontend/src/features/player/LinkedPlayer.tsx new file mode 100644 index 00000000..f6f86231 --- /dev/null +++ b/frontend/src/features/player/LinkedPlayer.tsx @@ -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