diff --git a/CHANGELOG.md b/CHANGELOG.md index 050c31b1..fabb98c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.103.0 — 2026-10-08 + +### New + +- Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page. + +### Fixed + +- Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too. +- iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen. +- The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line. + ## 0.102.0 — 2026-10-08 ### New diff --git a/VERSION b/VERSION index 7bb21aff..89eba2c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.102.0 +0.103.0 diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index bcdcb605..99ecc24f 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -167,12 +167,16 @@ class PlexClient: keys = ",".join(str(k) for k in rating_keys) return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}" - def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict: - """Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).""" + def create_collection( + self, section_key: str, title: str, item_rating_key: str, item_type: int = 1 + ) -> dict: + """Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey). + `item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection + made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after.""" uri = quote(self._metadata_uri(item_rating_key), safe="") mc = self._write( "POST", - f"/library/collections?type=1&smart=0§ionId={section_key}" + f"/library/collections?type={item_type}&smart=0§ionId={section_key}" f"&title={quote(title, safe='')}&uri={uri}", ) items = mc.get("Metadata", []) or [] diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 0dfdde83..16ad7215 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -176,6 +176,40 @@ def collections( } +@router.get("/collections/for/{item_rating_key}") +def collections_for_item( + item_rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """What a movie or show card's "Add to collection" lists: the editable collections of the title's + library (a collection lives in one library, and only a manual one can take a title — the same + `can_edit` the editor offers), each saying whether the title is in it already, by name.""" + movie = by_rating_key(db, PlexItem, item_rating_key) + title: PlexItem | PlexShow | None = ( + movie if movie is not None and movie.kind == "movie" else None + ) or by_rating_key(db, PlexShow, item_rating_key) + if title is None: + raise not_found("item") + member_of = set(title.collection_keys or []) + rows = ( + db.execute( + select(PlexCollection) + .where(PlexCollection.library_id == title.library_id) + .order_by(collated(PlexCollection.title, lang_for_user(user))) + ) + .scalars() + .all() + ) + return { + "collections": [ + {**_collection_card(c), "contains": c.rating_key in member_of} + for c in rows + if _collection_editable(c) + ] + } + + class Member(NamedTuple): """One collection member flattened to everything the Sort control can order by. The page's two legs are different tables (movies and shows), so there is no single query to ORDER BY — the @@ -407,27 +441,47 @@ def create_collection( seed = body.item_rating_key if not title or not seed: raise refused(400, "collection_fields_required") + seed_show = by_rating_key(db, PlexShow, seed) if library: lib = ( db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() ) else: # The "New collection" flow only knows the seed title → derive its library. - seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed) + seed_item = by_rating_key(db, PlexItem, seed) or seed_show lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None if lib is None: raise not_found("library") _reject_duplicate_name(db, lib.id, title) wrote = False + new_rk = "" try: with PlexClient(db) as plex: - new = plex.create_collection(lib.plex_key, title, seed) + # A show collection is made as one (Plex type 2): as a movie one it stayed empty. + new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1) wrote = True - col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) + new_rk = str(new.get("ratingKey") or "") + col = plex_sync.resync_collection(db, plex, lib, new_rk) col.editable = True db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() + if wrote and new_rk: + # It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the + # full sync preserves `editable` but creates a missing row read-only, so the collection + # would drop out of every "add to" list; and the duplicate-name check reads these rows, + # so a retry of the same name would make a second one on Plex. Plex made it with exactly + # the seed in it, so the seed's row says so too — otherwise the title showed unticked in + # the collection it was just added to, and the collection empty, until that sync. + db.add( + PlexCollection( + rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1 + ) + ) + Model = PlexItem if lib.kind == "movie" else PlexShow + if row := by_rating_key(db, Model, seed): + row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk])) + db.commit() raise _write_or_pending_error(wrote, e) from e return _collection_card(col) diff --git a/backend/tests/test_plex_client.py b/backend/tests/test_plex_client.py index 76362a4f..1e95db32 100644 --- a/backend/tests/test_plex_client.py +++ b/backend/tests/test_plex_client.py @@ -159,6 +159,10 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex): assert plex.seen[-1].method == "POST" assert params["title"] == "Best of: 90s" and params["sectionId"] == "3" assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42" + assert params["type"] == "1" # a movie collection unless told otherwise + # A show collection is made as one: made as a movie one, it came out empty in a TV section. + plex.create_collection("3", "Shows", "42", 2) + assert plex.seen[-1].url.params["type"] == "2" plex.routes["/library/collections"] = {} with pytest.raises(PlexError): diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index c90c2319..1c2e000b 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres. from datetime import datetime, timezone +import pytest from fastapi.testclient import TestClient from sqlalchemy import select @@ -14,6 +15,7 @@ from app.db import get_db from app.main import app from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User from app.plex.client import PlexError +from app.plex.lookups import by_rating_key from app.routes.plex import collections as coll_routes @@ -224,8 +226,9 @@ class _CollStub: if self.fail_on == name: raise PlexError("boom") - def create_collection(self, section_key, title, item_rating_key): + def create_collection(self, section_key, title, item_rating_key, item_type=1): self._maybe("create_collection") + self.created_type = item_type return {"ratingKey": "NEW"} def add_collection_item(self, rk, item): @@ -320,6 +323,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep): assert body["editable"] is True # create marks the new collection editable +@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)]) +def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type): + # Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out + # empty with a show seed, and refused the show when it was added after. + lib, user = _seed_editable(db) + db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series")) + db.commit() + stub = _CollStub() + client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create) + r = client.post( + "/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed} + ) + assert r.status_code == 200, r.text + assert stub.created_type == plex_type + + def test_create_resync_failure_is_422(db, monkeypatch, override_dep): # The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with # the honest "reconcile on the next sync" detail, not a false "write failed". @@ -337,6 +356,19 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep): assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} assert "create_collection" in stub.calls + # It exists on Plex: its row stays, editable, until the next sync fills it in — so it is offered + # where collections are added to, and the same name is not made twice. + row = by_rating_key(db, PlexCollection, "NEW") + assert row is not None and row.editable and row.title == "Fresh Coll" + # Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it. + assert row.child_count == 1 + assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or []) + again = client.post( + "/api/plex/collections", + json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, + ) + assert again.status_code == 409, again.text + assert stub.calls.count("create_collection") == 1 def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep): diff --git a/backend/tests/test_plex_collections_for_item.py b/backend/tests/test_plex_collections_for_item.py new file mode 100644 index 00000000..ece1025c --- /dev/null +++ b/backend/tests/test_plex_collections_for_item.py @@ -0,0 +1,86 @@ +"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the +title is in it already. DB lane (`db` fixture).""" + +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User + + +def _seed(db): + movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movies, shows]) + db.flush() + enriched = datetime(2024, 1, 1, tzinfo=timezone.utc) + db.add_all( + [ + PlexItem( + rating_key="m1", + title="Film", + kind="movie", + library_id=movies.id, + collection_keys=["cA"], + people_enriched_at=enriched, + ), + PlexItem( + rating_key="e1", + title="An episode", + kind="episode", + library_id=shows.id, + people_enriched_at=enriched, + ), + PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]), + # The movie library's: two manual ones it can go into, and three it cannot. + PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True), + PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True), + PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False), + PlexCollection( + rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True + ), + PlexCollection( + rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True + ), + # The show library's own. + PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True), + ] + ) + user = User(email="collfor@example.com") + db.add(user) + db.commit() + return user + + +def _get(db, monkeypatch, override_dep, rk): + user = _seed(db) + monkeypatch.setattr(state, "_configured_cache", True) + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: user) + return TestClient(app).get(f"/api/plex/collections/for/{rk}") + + +def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it( + db, monkeypatch, override_dep +): + resp = _get(db, monkeypatch, override_dep, "m1") + assert resp.status_code == 200 + got = [(c["id"], c["contains"]) for c in resp.json()["collections"]] + assert got == [("cA", True), ("cB", False)] + + +def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep): + resp = _get(db, monkeypatch, override_dep, "s1") + assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)] + + +def test_an_episode_is_not_a_collection_member(db, monkeypatch, override_dep): + assert _get(db, monkeypatch, override_dep, "e1").status_code == 404 + + +def test_an_unknown_key_is_not_found(db, monkeypatch, override_dep): + assert _get(db, monkeypatch, override_dep, "nope").status_code == 404 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a05fd31..59c5aa18 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -711,3 +711,7 @@ of a full-width panel and nothing on the right. | 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it | | 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules | | 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) | +| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch | +| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii | +| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine | +| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" | diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index fd0c7582..8ad527a0 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { ListPlus, Plus } from "lucide-react"; +import { ListPlus } from "lucide-react"; import { YouTubeIcon } from "../lib/brandIcons"; -import { MenuCheck } from "./ui/MenuCheck"; import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; -import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; +import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { IconButton } from "./ui/IconButton"; -import { Input } from "./ui/Input"; +import { MembershipMenu } from "./ui/MembershipMenu"; import { SURFACE } from "./ui/surfaces"; // A small anchored menu for toggling a video's membership in the user's playlists, with inline -// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style -// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and -// the input swallowing non-Escape keys, since character and arrow keys belong to the text field. +// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). export default function AddToPlaylist({ videoId, variant = "ghost", @@ -94,9 +90,7 @@ function MembershipPanel({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const { ref: fadeRef, style: fadeStyle } = useScrollFade(); const open = pop.open; - const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const membership = useQuery({ @@ -115,13 +109,17 @@ function MembershipPanel({ // A failure is said, and the list refreshed either way. With no catch, a playlist deleted on // another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an - // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. + // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that + // landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it. async function toggle(pl: Playlist) { if (busy) return; setBusy(true); try { if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId); + qc.setQueryData(qk.playlistMembership(videoId), (old) => + old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), + ); } catch (e) { notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); } @@ -129,19 +127,20 @@ function MembershipPanel({ setBusy(false); } - async function createAndAdd() { - const name = newName.trim(); - if (!name || busy) return; + async function createAndAdd(name: string) { + if (busy) return false; setBusy(true); + let made = false; try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); - setNewName(""); + made = true; } catch (e) { notifyActionError(e, t("playlists.addFailed")); } refresh(); setBusy(false); + return made; } // All of the user's playlists are add targets, including YouTube-linked ones — adding @@ -149,67 +148,40 @@ function MembershipPanel({ const lists = membership.data ?? []; return ( - <> - - {/* A React portal still propagates events up the REACT tree, so a click inside this panel - would otherwise reach the feed card that hosts the trigger and start playback. */} -
e.stopPropagation()}> -
- {t("playlists.addToPlaylistTitle")} -
-
- {lists.length === 0 && !membership.isLoading && ( -
{t("playlists.noneYet")}
- )} - {lists.map((pl) => ( - toggle(pl)} - // NOT `disabled={busy}`: the native disabled attribute would blur the focused item to - // mid-request and permanently kill roving focus (useListNavigation listens on - // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is - // guarded without touching focus; `aria-busy` on the list conveys the pending state. - label={playlistName(pl, t)} - className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" - > - - {playlistName(pl, t)} - {(pl.source === "youtube" || pl.yt_playlist_id) && ( - - )} - - ))} -
-
- - setNewName(e.target.value)} - onKeyDown={(e) => { - // Escape bubbles to the popover's dismiss; every other key is text editing here and - // must not reach the surrounding menu's list navigation. - if (e.key === "Escape") return; - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - void createAndAdd(); - } - }} - placeholder={t("playlists.newPlaylist")} - /> -
-
-
- + + ({ + id: pl.id, + label: playlistName(pl, t), + checked: !!pl.has_video, + extra: + pl.source === "youtube" || pl.yt_playlist_id ? ( + + ) : undefined, + }))} + onToggle={(row) => { + const pl = lists.find((p) => p.id === row.id); + if (pl) void toggle(pl); + }} + status={ + membership.isError && !membership.data + ? "error" + : membership.isLoading + ? "loading" + : "ready" + } + onRetry={() => void membership.refetch()} + emptyText={t("playlists.noneYet")} + errorText={t("common.loadError")} + busy={busy} + create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }} + /> + ); } diff --git a/frontend/src/components/ChannelTags.editor.dom.test.tsx b/frontend/src/components/ChannelTags.editor.dom.test.tsx new file mode 100644 index 00000000..00b908b3 --- /dev/null +++ b/frontend/src/components/ChannelTags.editor.dom.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +// +// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags +// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +import { ChannelTagsControl } from "./ChannelTags"; +import { api } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("the channel tag editor", () => { + it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => { + vi.spyOn(api, "tags").mockRejectedValue(new Error("down")); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + {}} + onCreate={() => {}} + /> + + , + ); + fireEvent.click(screen.getByLabelText("channels.row.editTags")); + const alert = await screen.findByRole("alert", { hidden: true }); + expect(alert.textContent).toContain("common.loadError"); + expect(screen.queryByText("channels.tags.noneYet")).toBeNull(); + expect(alert.closest("[role=menu]")).toBeNull(); + expect(await screen.findByText("common.retry")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 81177e85..6df99b54 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -1,17 +1,15 @@ import { useRef, useState } from "react"; import { Plus, Sparkles } from "lucide-react"; -import { MenuCheck } from "./ui/MenuCheck"; import { useTranslation } from "react-i18next"; import { type Tag } from "../lib/api"; -import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover"; +import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { useScrollFade } from "../lib/useScrollFade"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { IconButton } from "./ui/IconButton"; import { Chip, DismissibleChip } from "./ui/Chip"; import { chipClass } from "./ui/chipStyles"; -import { Input } from "./ui/Input"; +import { MembershipMenu } from "./ui/MembershipMenu"; +import { useTags } from "../lib/useTags"; import { SURFACE } from "./ui/surfaces"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail @@ -194,8 +192,9 @@ function SuggestedChipsLane({ } /** The editor body: toggle the user's existing tags on/off for this channel, plus an inline - * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel - * detail page in Part 2; internal for now.) */ + * "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's + * `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while + * the editor is open), so a failed or pending load is said as such, not as "no tags yet". */ function TagEditorMenu({ userTags, attachedIds, @@ -208,71 +207,27 @@ function TagEditorMenu({ onCreate: (name: string) => void; }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(""); - const { ref: fadeRef, style: fadeStyle } = useScrollFade(); + const tags = useTags(); const attached = new Set(attachedIds); - const submit = () => { - const name = draft.trim(); - if (!name) return; - onCreate(name); - setDraft(""); - }; return ( -
-
- {userTags.map((tg) => { - const on = attached.has(tg.id); - return ( - onToggle(tg.id)} - label={tg.name} - className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${ - on ? "text-fg" : "text-muted" - }`} - > - - {tg.name} - - ); - })} - {userTags.length === 0 && ( -

{t("channels.tags.noneYet")}

- )} -
-
- setDraft(e.target.value)} - onKeyDown={(e) => { - // Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key — - // arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's - // list-navigation/typeahead handlers on the surrounding surface. - if (e.key === "Escape") return; - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - submit(); - } - }} - maxLength={64} - placeholder={t("channels.tags.createPlaceholder")} - /> - - - -
-
+ ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))} + onToggle={(row) => onToggle(row.id as number)} + status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"} + onRetry={() => void tags.refetch()} + emptyText={t("channels.tags.noneYet")} + errorText={t("common.loadError")} + create={{ + placeholder: t("channels.tags.createPlaceholder"), + maxLength: 64, + submitLabel: t("channels.tags.create"), + onCreate: (name) => { + onCreate(name); + return true; + }, + }} + /> ); } @@ -473,6 +428,7 @@ export function ChannelTagsControl({ (i === 0 ? { ...it, separatorBefore: true } : it)) : target.plex ? plexActions - .items(target.plex, { play: false, pin: false }) + .items(target.plex, { play: false, pin: false, anchor: menu.anchor }) .map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)) : target.kind === "channel" || target.kind === "playlist" ? [{ ...openPage.item(target), separatorBefore: true }] diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx index 7de92f69..b486e42a 100644 --- a/frontend/src/components/PaletteLauncher.tsx +++ b/frontend/src/components/PaletteLauncher.tsx @@ -28,7 +28,8 @@ function useCap(): string { // words matched nothing. /** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it - * too; left out of a phone's narrow header, where there is no keyboard to press it on. */ + * too; left out of a phone's narrow header and of every touch screen, where there is (usually) no + * keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */ export function PaletteKeycap() { const { t } = useTranslation(); const cap = useCap(); @@ -37,7 +38,7 @@ export function PaletteKeycap() { onClick={openPalette} aria-label={`${t("palette.open")} (${cap})`} title={`${t("palette.open")} (${cap})`} - className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden" + className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden" > {cap} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 774e903b..e7520c90 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -444,6 +444,72 @@ describe("PlayerModal — the side arrows", () => { }); }); +// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom +// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the +// top. A cutout given only as a share of the height lay over that button on a short player (a small +// window, a browser zoomed to 150 %): the click paused instead. +describe("PlayerModal — the click layer over the video", () => { + const layers = () => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + return screen.getAllByTestId("yt-click-layer"); + }; + const box = (el: HTMLElement) => { + const { left, right, width, top, bottom } = el.style; + return { left, right, width, top, bottom }; + }; + + it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => { + const [picture, column] = layers(); + expect(box(picture!)).toEqual({ + left: "0px", + right: "88px", + width: "", + top: "max(12%, 56px)", + bottom: "max(22%, 104px)", + }); + expect(box(column!)).toEqual({ + left: "", + right: "0px", + width: "88px", + top: "max(12%, 56px)", + bottom: "max(22%, 140px)", + }); + }); + + // In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's + // bottom, so a box measured from the stage's would start that bar's height too low again. + it.each([ + ["the windowed card", false, /(^|\s)relative(\s|$)/], + ["true fullscreen", true, /(^|\s)bottom-14(\s|$)/], + ])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => { + const els = layers(); + const stage = document.querySelector(".player-stage")!; + if (fullscreen) { + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => stage, + }); + act(() => void document.dispatchEvent(new Event("fullscreenchange"))); + } + try { + for (const el of screen.getAllByTestId("yt-click-layer")) { + const wrapper = el.parentElement!; + expect(wrapper).not.toBe(stage); + expect(wrapper.parentElement).toBe(stage); + expect(wrapper.className).toMatch(inset); + expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/); + } + expect(els).toHaveLength(2); + } finally { + delete (document as { fullscreenElement?: unknown }).fullscreenElement; + } + }); +}); + // At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel // row. The card's width is capped from the window height less what sits under the video, measured. describe("PlayerModal — fits the window's height", () => { @@ -485,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => { removeEventListener: () => {}, }) as unknown as MediaQueryList, ); + // jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or + // every later test's media query would read that bare stub. + let added = false; beforeEach(() => { - window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia; + if (typeof window.matchMedia === "function") return; + window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia; + added = true; + }); + afterEach(() => { + // The spy goes first: restoring it later would put the bare stub back. + vi.restoreAllMocks(); + if (added) delete (window as { matchMedia?: unknown }).matchMedia; + added = false; }); it("puts the details beside the video, the description open, and sizes the card for both", async () => { @@ -591,3 +668,40 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(screen.queryByTestId("player-description")).toBeNull(); }); }); + +// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's +// true-fullscreen button did nothing there. The window-filling view is then the biggest one. +describe("PlayerModal — the docked bar's fullscreen", () => { + const maximised = (api_: boolean) => { + if (api_) + Object.defineProperty(Element.prototype, "requestFullscreen", { + configurable: true, + value: () => Promise.resolve(), + }); + // Brave's stand-in otherwise: a