Compare commits

..
23 Commits
Author SHA1 Message Date
peter 75f4efde98 release: 0.103.0 (bug/yt-fullscreen-button) 2026-10-08 22:50:52 +02:00
peter c0059a0c4b release: 0.103.0 notes within the item length cap 2026-10-08 22:36:15 +02:00
peter a29cadf03e release: 0.103.0 notes and version 2026-10-08 22:30:56 +02:00
peter 83886494ce fix(ui): a membership menu re-homes focus a frame later, once its new rows are on the list
Rows that just mounted join the menu's navigation list a render after the status effect ran: a row
focused that early was unknown to it, and the arrows did nothing from it (seen live after a keyboard
Retry). The retry test now arrows on from the row it lands on.
2026-10-08 22:16:47 +02:00
peter 4eaa55385f fix(plex): a collection kept after a failed refresh holds its seed title
When Plex made the collection and only the read after it failed, the kept row had no members: the
title showed unticked in the collection it had just been added to, the collection empty, and the
name stayed in the field for a retry that could only be refused as taken. The seed's row now names
the collection (child_count 1), and the panel clears the field on that refusal as on success. Also
held: the panel's lines sit beside its list, and a keyboard Retry lands on the first row.
2026-10-08 22:12:43 +02:00
peter 7541b1b016 fix(ui): membership menus keep their lines beside the list and focus in the panel
The loading / failed-load lines and their Retry sat inside role=menu, which may own its items only:
the three surfaces now open with `listInside` and the rows sit in a PopoverList. A keyboard Retry
unmounted the focused button and left focus on <body> with the menu open, the arrows dead: focus is
put back on the first row, else the field, else the new Retry. The channel tag editor said a failed
tag load as "no tags yet"; it reads the catalog query's state now. The playlist panel's name and the
tag editor's error state are held by tests.
2026-10-08 22:12:43 +02:00
peter 24ad17fc7d test: hold the show collection's type, an unknown title's 404 and Brave's fullscreen stand-in 2026-10-08 21:18:48 +02:00
peter 48bd038c5f fix(plex): a collection created on Plex keeps its editable row when the refresh read fails
The rollback dropped the new row: the next sync recreated it read-only, out of every 'add to' list, and the duplicate-name check let a retry make a second same-named collection on Plex.
2026-10-08 21:18:48 +02:00
peter 0fce732497 fix(plex): a playlist row's menu adds its title to a collection too
The row menu called the title's action list without the anchor the panel opens beside, and the optional parameter dropped the row silently. The anchor is now required, so every host menu decides.
2026-10-08 21:18:48 +02:00
peter 7e94da72b9 fix(ui): one membership menu for playlists, collections and tags
Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
2026-10-08 21:18:48 +02:00
peter fadb4f17a9 fix(plex): subtitles draw only the player's own track, lifted in the player's own pixels
A direct-played file's own subtitle stream (WebKit lists an mp4's mov_text) was switched to hidden and drawn under any choice, Off included: only the player's <track> is kept hidden and read, other subtitle tracks are switched off. The lift was measured in screen pixels and applied inside the x1.25 windowed player, and re-measured off a box carrying its own (or a mid-slide) transform: an untransformed outer box is measured, the distance divided by the scale, and a cue that just appeared is placed, not slid from where the last one was left.
2026-10-08 21:18:48 +02:00
peter 57a81c5643 fix(plex): subtitles survive hls.js and a remounted track
hls.js emptied the cues of every text track on the element when a manifest
loaded or the media attached, our own <track> included, so a VTT that loaded
first showed nothing for the session; its subtitle and caption controllers are
no longer built. A remounted default <track> was switched to showing by the
browser after the hide had run, drawing the cues natively under ours; the hide
now follows every added track and mode change.
2026-10-08 19:35:47 +02:00
peter dae0b80f6a feat(plex): add a movie or show to a collection from its card menu
"Add to collection…" opens a panel beside the card: the title's library's
manual collections tick it in or out, and a name + Enter creates a new one with
the title in it. It used to take the Collections page, a new collection, its
name and a title search, then another search per title. A lean endpoint lists
the collections with whether each holds the title. Admin only, as on the
title's page.

Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
2026-10-08 03:09:55 +02:00
peter 75093458c9 feat(plex): subtitles rise above the control bar while it shows
On an iPad the bar covered the text whenever it was up: the browser places a
native cue by its line, which nothing animates. The track now stays loaded as
hidden and PlexSubtitles draws the active cues, measured against the bar and
lifted above it in 200 ms, back down when it hides. The viewer's size, colour,
background and outline settings style the text directly. Also fixes a test
typing slip from the search pill change.
2026-10-08 02:59:13 +02:00
peter 5db6dc8ee6 docs(design): log the touch header search 2026-10-08 02:52:39 +02:00
peter 45a9ceae25 fix(ui): the header search has room on a tablet
On touch the 44 px key cap and Go filled a 44 px pill edge to edge and the
placeholder was cut. The key cap is left out on touch (⌘K still works), the
pill grows to 52 px so a ring shows round Go, the box's floor counts the
controls beside the text, and Go drops to its icon by the column's width,
not the screen's.
2026-10-08 02:52:32 +02:00
peter 5249c41edc fix(player): a fullscreen button the browser cannot honour is not shown
Brave on iPadOS switches element fullscreen off, patches requestFullscreen with
a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button
and the YouTube docked bar's did nothing. Support is now read off
Element.prototype and the buttons are left out without it (owner). A stand-in
that returns a boolean no longer throws. A test's bare matchMedia stub no
longer outlives its describe.
2026-10-08 02:50:51 +02:00
peter c3664826fe fix(ui): a caption never opens under a finger
On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the
tap as a hover and swallowed its click, so every rail button needed two taps,
and with no mouseleave from a finger the caption stayed up. Tooltip now opens
for a mouse and a key's focus only (owner: no captions on touch).
2026-10-08 02:45:48 +02:00
peter c9b8fe15a3 fix(ui): a scroller with no edge to fade carries no mask
iPad WebKit kept a scrolled container's mask stuck to its content: at the
bottom of a scrolled page the last strip, a table's pager, sat under a dark
band. With nothing to fade there is no mask; isolation keeps its layer.
2026-10-08 02:43:58 +02:00
peter 1044eaf8a9 fix(plex): the player holds the page still and covers it whole; the row drops the countdown
On an iPad a drag on the video scrolled the page behind the player, and in Brave
dvh did not follow the toolbar, so a strip of the page showed under it. The
page is now locked without overscroll on a black root while the player is open,
and the player is sized from its fixed box. The time row keeps the position,
the length and the end clock; the countdown is gone (owner).
2026-10-08 02:42:31 +02:00
peter ca949d22de fix(player): the channel line wraps instead of squeezing the meta into a column
On a narrow card (a tablet, a zoomed window) the unwrapped row of name, meta and
actions left the meta a one-word column. The row now wraps: the meta keeps at
least 12rem, and the actions move down together.
2026-10-08 02:25:58 +02:00
peter 49a9319f84 fix(player): the click layer clears only the fullscreen button's column, not a full-width band
Measured: across the full width YouTube needs only its seek bar (up to 96 px
from the bottom); the 140 px is the fullscreen button's, at the right end. A
full-width 140 px band handed clicks on the picture to the iframe, which then
takes the keyboard focus and our shortcuts with it. The layer is now two boxes,
hidden from assistive tech (a mouse surface; the keys are its shortcuts), and
the tests render the big view too, where the docked bar sits under the iframe.
2026-10-08 01:42:39 +02:00
peter 70d56ab939 fix(player): YouTube's fullscreen button stays clickable on a short player
The click layer over the embed left the bottom 22 % of the stage free. YouTube's
new embed keeps its fullscreen button 80-128 px above the iframe's bottom, so on
a stage under ~580 px (a small window, a browser zoomed to 150 %) the layer lay
over it and a click paused instead. The cutouts now have px floors (56 top, 140
bottom) and are measured against the iframe's own box.
2026-10-08 01:36:36 +02:00
49 changed files with 2101 additions and 337 deletions
+12
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.102.0
0.103.0
+7 -3
View File
@@ -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&sectionId={section_key}"
f"/library/collections?type={item_type}&smart=0&sectionId={section_key}"
f"&title={quote(title, safe='')}&uri={uri}",
)
items = mc.get("Metadata", []) or []
+57 -3
View File
@@ -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)
+4
View File
@@ -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):
+33 -1
View File
@@ -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):
@@ -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
+4
View File
@@ -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" |
+49 -77
View File
@@ -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<Playlist[]>(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 (
<>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* 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. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fadeRef}
style={fadeStyle}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> 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"
>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => 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")}
/>
</div>
</div>
</PopoverSurface>
</>
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-60 p-2`}
ariaLabel={t("playlists.addToPlaylistTitle")}
listInside
>
<MembershipMenu
heading={t("playlists.addToPlaylistTitle")}
rows={lists.map((pl) => ({
id: pl.id,
label: playlistName(pl, t),
checked: !!pl.has_video,
extra:
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
) : 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 }}
/>
</PopoverSurface>
);
}
@@ -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<typeof import("react-i18next")>();
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(
<QueryClientProvider client={qc}>
<FloatingTree>
<ChannelTagsControl
userTags={[]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
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();
});
});
+26 -70
View File
@@ -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 (
<div className="flex flex-col">
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<PopoverItem
key={tg.id}
// Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on}
onSelect={() => 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"
}`}
>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
})}
{userTags.length === 0 && (
<p className="text-2xs text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => 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")}
/>
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
label={t("channels.tags.create")}
>
<Plus />
</IconButton>
</div>
</div>
<MembershipMenu
dense
rows={userTags.map((tg) => ({ 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({
<PopoverSurface
popover={editPop}
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
listInside
>
<TagEditorMenu
userTags={userTags}
+1 -1
View File
@@ -199,7 +199,7 @@ function useHomeCardMenu({
.map((it, i) => (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 }]
+3 -2
View File
@@ -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}
</Pressable>
@@ -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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} 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 <video>-only requestFullscreen on HTMLElement.prototype.
else
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length;
};
afterEach(() => {
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
it("is offered where the browser can fullscreen an element", () => {
expect(maximised(true)).toBe(2);
});
it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1);
});
});
+128 -81
View File
@@ -6,6 +6,7 @@ import {
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
@@ -56,7 +57,7 @@ import {
formatCompact,
relativeTime,
} from "../lib/format";
import { fullscreenElement } from "../lib/fullscreen";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
@@ -106,6 +107,35 @@ const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the
// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button
// above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never
// shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the
// layer lies over that button and a click on it pauses instead. Only the right-hand column needs the
// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the
// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any
// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap.
// Px, not rem: the text-size slider does not grow YouTube's controls.
const YT_TOP_CONTROLS_PX = 56;
const YT_SEEK_BAR_PX = 104;
const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 };
// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the
// column above that button.
const YT_CLICK_LAYER: CSSProperties[] = [
{
left: 0,
right: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`,
},
{
right: 0,
width: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`,
},
];
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
@@ -115,6 +145,9 @@ const metaSep = (
</span>
);
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
// Beside the channel name the meta takes what is left of the line, but never less than this: under it
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
const META_MIN = "min-w-48 flex-1";
export default function PlayerModal({
video,
@@ -689,39 +722,48 @@ export default function PlayerModal({
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
measured against the iframe it sits in (in the big views that excludes our docked bar)
and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden
from assistive tech: the keyboard has the shortcuts it stands for.
Armed in the big views too (that is what keeps wheel volume working after F, the reported
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null &&
YT_CLICK_LAYER.map((box, i) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
</div>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
Armed in the big views too (that is what keeps wheel volume working after F, the reported
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
@@ -771,13 +813,18 @@ export default function PlayerModal({
</Button>
)}
<span className="h-4 w-px bg-white/20" />
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
@@ -922,10 +969,13 @@ export default function PlayerModal({
</Button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
{/* Channel + meta on one line, with the actions pushed to the right.
When navigated to a linked video we only have its author (from the player),
so we show that as plain text and hide feed-video-specific bits. */}
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
so we show that as plain text and hide feed-video-specific bits.
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
one-word column between the name and the buttons. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
@@ -973,7 +1023,7 @@ export default function PlayerModal({
)}
{!navigated ? (
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
<span>
@@ -1004,7 +1054,7 @@ export default function PlayerModal({
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{detail.data?.view_count != null && (
<span>
@@ -1037,51 +1087,48 @@ export default function PlayerModal({
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
// Narrow: the actions end the channel line. Wide: their own line in the panel.
className={wide ? undefined : "ml-auto"}
>
<Maximize />
</IconButton>
)}
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
)}
{!navigated && onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
{!navigated && (
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
)}
</div>
{wide && (
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
}));
import PlexPlaylistView from "./PlexPlaylistView";
import AddToPlaylist from "./AddToPlaylist";
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import TagManager from "./TagManager";
import { api, HttpError, type PlexCard } from "../lib/api";
@@ -157,6 +157,64 @@ describe("the add-to-playlist popover", () => {
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("playlists.noneYet")).toBeNull();
});
it("opened from a card's menu, is a menu named for itself, its lines beside the list", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
const anchor = document.createElement("button");
document.body.append(anchor);
render(
<QueryClientProvider client={client()}>
<AddToPlaylistAt videoId="v1" anchor={anchor} onClose={() => {}} />
</QueryClientProvider>,
);
// The menu row that opened it is gone: its own name, not a label pointing at that row. jsdom
// computes no name for the unpositioned surface, so read what names it.
const menu = await waitFor(() => {
const found = document.querySelector(
'[role="menu"][aria-label="playlists.addToPlaylistTitle"]',
);
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.closest("[role=menu]")).toBeNull();
anchor.remove();
});
it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => {
const lists = vi.spyOn(api, "playlists");
lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never);
// The refetch after the write never answers: the tick must not wait for it.
lists.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never);
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never);
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const row = await screen.findByRole("menuitemcheckbox", { hidden: true });
fireEvent.click(row);
await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true"));
fireEvent.click(row);
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
@@ -0,0 +1,139 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, HttpError, type PlexCollection } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { MembershipMenu } from "./ui/MembershipMenu";
import { SURFACE } from "./ui/surfaces";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
// collection, its name, a search for the title, then another search to add the next one. Its body is
// the same MembershipMenu as the video card's "Add to playlist".
export interface PlexCollectionTarget {
ratingKey: string;
anchor: Element;
}
export default function PlexCollectionAddAt({
target,
onClose,
}: {
target: PlexCollectionTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
offset: 6,
hostsInput: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
const [busy, setBusy] = useState(false);
const { ratingKey } = target;
const forKey = qk.plexCollections("for", ratingKey);
const list = useQuery({
queryKey: forKey,
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
// all show membership.
function refresh() {
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
// before its refetch returned would otherwise repeat the write instead of undoing it.
async function toggle(c: PlexCollection & { contains: boolean }) {
if (busy) return;
setBusy(true);
try {
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
async function create(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
made = true;
} catch (e) {
made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
return made;
}
const collections = list.data?.collections ?? [];
return (
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-64 p-2`}
ariaLabel={t("plex.collectionAdd.title")}
listInside
>
<MembershipMenu
heading={t("plex.collectionAdd.title")}
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
onToggle={(row) => {
const c = collections.find((x) => x.id === row.id);
if (c) void toggle(c);
}}
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
onRetry={() => void list.refetch()}
emptyText={t("plex.collectionAdd.none")}
errorText={t("plex.collectionAdd.loadFailed")}
busy={busy}
create={{
placeholder: t("plex.collection.newCollection"),
ariaLabel: t("plex.collection.namePlaceholder"),
onCreate: create,
}}
/>
</PopoverSurface>
);
}
/** A create refused only because the read after it failed: the collection is on Plex, with this
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
function madeOnPlex(e: unknown): boolean {
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
return d?.reason === "collection_pending_sync";
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
old: T | undefined,
id: string,
): T | undefined {
if (!old) return old;
return {
...old,
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
};
}
+197 -4
View File
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PlexCard } from "../lib/api";
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -101,21 +102,27 @@ function Grid({
);
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
function setup(
c: PlexCard,
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
{ admin = false }: { admin?: boolean } = {},
) {
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={qc}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
return { handlers, qc };
}
// The card root holds the menu's trigger props (right-click anywhere on it).
const rightClick = () =>
@@ -223,3 +230,189 @@ describe("a Plex title's menu", () => {
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
});
});
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
// search for the title. From the card's menu it is a tick, or a name and Enter.
describe("a Plex title's menu — Add to collection", () => {
const coll = (id: string, title: string, contains: boolean) =>
({ id, title, contains }) as PlexCollection & { contains: boolean };
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
setup(card({}), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "show" }), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "episode" }), {}, { admin: true });
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
cleanup();
setup(card({}));
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
});
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
});
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const row = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
fireEvent.click(await row("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
// One write at a time: the next tick waits for this one to settle.
await vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
fireEvent.click(await row("Alpha"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
const name = screen.getByLabelText("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: " Rainy days " } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
});
const checkRow = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
const idle = () =>
vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
const list = vi.spyOn(api, "plexCollectionsFor");
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
// The refetch after the write never answers: the tick must not wait for it.
list.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
await idle();
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("refreshes a show's own page too, whose collections come with the show", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cB", "Beta", false)],
});
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
const invalidate = vi.spyOn(qc, "invalidateQueries");
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await idle();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
});
it("is a named menu that says it is loading, not that there are no collections", async () => {
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
const menu = await vi.waitFor(() => {
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
expect(screen.getByText("common.loading")).toBeTruthy();
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
});
it("says a failed load as one, with a retry — not as no collections", async () => {
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
// A menu owns its items only: the line about the list sits beside it, not in it.
expect(document.querySelector("[role=menu]")).not.toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
list.mockResolvedValue({
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
});
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!;
retry.focus();
fireEvent.click(retry);
const beta = await checkRow("Beta");
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
// …and the menu's own arrows carry on from there.
fireEvent.keyDown(beta, { key: "ArrowDown" });
expect(document.activeElement).toBe(await checkRow("Gamma"));
});
it("clears the name when Plex made the collection and only the read after it failed", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
const create = vi
.spyOn(api, "plexCreateCollection")
.mockRejectedValue(
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: "Noir" } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
// It exists now: a second Enter on the kept name could only be refused as taken.
await vi.waitFor(() => expect(name.value).toBe(""));
});
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
() => new Promise((resolve) => (answer = resolve)),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
name.focus();
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
const alpha = await checkRow("Alpha");
fireEvent.keyDown(name, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
name.focus();
fireEvent.keyDown(name, { key: "ArrowUp" });
expect(document.activeElement).toBe(await checkRow("Beta"));
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ export function PlexMenu({
<ContextMenu
menu={menu}
ariaLabel={t("plex.menu")}
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
/>
{actions.panel}
</>
@@ -857,3 +857,73 @@ describe("the seek bar (C3)", () => {
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
});
});
describe("the time in the row", () => {
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
expect(row.textContent).not.toMatch(/-\s*48:20/);
expect(row.textContent).toMatch(/\(.+\)/);
});
});
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
// own background under the player.
describe("the page under the player", () => {
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
const root = document.documentElement.style;
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(document.body.style.overflow).toBe("hidden");
expect(root.overscrollBehavior).toBe("none");
expect(root.backgroundColor).toBe("black");
cleanup();
expect(document.body.style.overflow).toBe("");
expect(root.overscrollBehavior).toBe("");
expect(root.backgroundColor).toBe("");
});
it("is covered by a player sized from its fixed box, not from dvh", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
// nothing there. It is shown only where the browser has the API on Element.prototype.
describe("the fullscreen button", () => {
const withApi = (on: boolean) => {
if (on)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
};
afterEach(() => withApi(false));
it("is there where the browser can fullscreen an element", async () => {
withApi(true);
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
});
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
withApi(false);
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
});
@@ -21,6 +21,12 @@ import { api, type PlexItemDetail } from "../lib/api";
import { pushLayer, removeLayer } from "../lib/layerStack";
beforeAll(() => {
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
class Stub {
observe() {}
unobserve() {}
+50 -23
View File
@@ -45,7 +45,6 @@ import {
DEFAULT_PREFS,
fmt,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
@@ -70,7 +69,7 @@ import {
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
isActivationTarget,
@@ -84,7 +83,8 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { trapTab } from "./ui/dialogFocus";
import PlexSubtitles from "./PlexSubtitles";
import { lockBodyScroll, trapTab } from "./ui/dialogFocus";
import {
pushLayer,
removeLayer,
@@ -150,10 +150,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const videoRef = useRef<HTMLVideoElement | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
// The bottom band, which the subtitles keep above while it shows.
const bottomBarRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body>. The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
// The page stays put under the player: on an iPad a drag on the video scrolled (or rubber-banded) the
// document behind it, and the strip it moved showed the page's own background under the player.
// Locked, with no overscroll, and the root painted black so a sub-pixel gap reads as the player's.
useEffect(() => {
const unlock = lockBodyScroll();
const root = document.documentElement.style;
const before = { background: root.backgroundColor, overscroll: root.overscrollBehavior };
root.backgroundColor = "black";
root.overscrollBehavior = "none";
return () => {
unlock();
root.backgroundColor = before.background;
root.overscrollBehavior = before.overscroll;
};
}, []);
// The key of the last press that landed on faded bands, while it is held (see onKey).
const blindKeyRef = useRef<string | null>(null);
const durationRef = useLatest(duration);
@@ -403,7 +420,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
detail,
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
restartSession,
setAudioTrack: (track: number) => engine.setAudioTrack(track),
patchPrefs,
@@ -1089,36 +1105,32 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
onClick={wake}
onWheel={onWheel}
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
// out at 80vw×80dvh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen; `dvh` so a tablet's toolbar does not push the bar off it). We use `transform: scale` rather than `zoom` deliberately:
// out at 80%×80% and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen). The size is a share of the fixed box's own containing block,
// the layout viewport, not `dvh`: in an iPad browser built on its own web view (Brave) `dvh` did
// not follow the toolbar, the player came out a few px short and the page showed under it. We use `transform: scale` rather than `zoom` deliberately:
// under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
// pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
// both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
//
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80dvh, the
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80%×80%, the
// scaled layer is composited letterboxed and the video never grows past its windowed size (the UA
// does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to
// the full screen (100vw×100dvh) with NO transform — the flex box then centres the video and its
// the full screen (the whole containing block) with NO transform — the flex box then centres the video and its
// `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the
// clientX/rect math stay correct under the identity transform.
style={{
cursor: uiVisible ? "default" : "none",
...(fs
? { width: "100vw", height: "100dvh" }
? { width: "100%", height: "100%" }
: {
width: "80vw",
height: "80dvh",
width: "80%",
height: "80%",
transform: "scale(1.25)",
transformOrigin: "top left",
}),
}}
>
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
player owns the only <video> on screen. */}
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
prefs.subShadow,
prefs.subShadowColor,
)}}`}</style>
<video
ref={(el) => {
videoRef.current = el;
@@ -1134,8 +1146,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
onClick={onPictureClick}
playsInline
>
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
{/* Text subtitles as a native track (same-origin → sent with the session cookie), kept
`hidden`: the browser fetches and parses the VTT, PlexSubtitles below draws the cues. */}
{selectedSub && subShowable && (
<track
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
@@ -1151,6 +1163,17 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
/>
)}
</video>
<PlexSubtitles
video={videoEl}
pos={prefs.subPos}
size={prefs.subSize}
color={prefs.subColor}
background={prefs.subBg}
shadowPx={prefs.subShadow}
shadowColor={prefs.subShadowColor}
avoid={uiVisible}
avoidRef={bottomBarRef}
/>
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
@@ -1314,6 +1337,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{/* Bottom controls */}
<div
ref={bottomBarRef}
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible
@@ -1462,7 +1486,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
pushes later while paused, since the remaining time then rides the advancing clock. */}
{duration > 0 && (
@@ -1616,9 +1639,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
/>
)}
</div>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
did nothing there, and the windowed player already fills the window. */}
{canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
</div>
@@ -28,6 +28,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
thumb: "",
}) as unknown as PlexCard;
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void) {
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
const onPlay = vi.fn();
const onBack = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) client.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<QueryClientProvider client={client}>
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
</QueryClientProvider>,
);
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
});
describe("PlexPlaylistView · a row's menu", () => {
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
mount([movie("1")], undefined, { admin: true });
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect(menuRows()).toContain("plex.collectionAdd.row");
});
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
mount([movie("1"), ep("e1", 1, 1)]);
await screen.findByText("Movie 1");
+1 -1
View File
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
? []
: menuTarget.kind === "item"
? [
...titleActions.items(menuTarget.item),
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
]
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// The Plex player draws its subtitles itself. With the browser drawing them, the control bar covered
// the text on an iPad whenever it was up: a native cue is placed by `line`, which nothing animates.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { createRef } from "react";
import PlexSubtitles from "./PlexSubtitles";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
function fakeVideo() {
const video = document.createElement("video");
const list: FakeTrack[] = [];
const tracks = Object.assign(new EventTarget(), {
get length() {
return list.length;
},
[Symbol.iterator]: () => list[Symbol.iterator](),
});
Object.defineProperty(video, "textTracks", { value: tracks });
Object.defineProperty(video, "clientHeight", { value: 1000 });
const make = (own: boolean) => {
const track: FakeTrack = Object.assign(new EventTarget(), {
kind: "subtitles",
mode: "hidden" as TextTrackMode,
activeCues: [] as unknown[],
});
if (own) {
const el = document.createElement("track");
Object.defineProperty(el, "track", { value: track });
video.appendChild(el);
}
list.push(track);
return track;
};
const cuesOn = (track: FakeTrack, texts: string[]) =>
act(() => {
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
track.dispatchEvent(new Event("cuechange"));
});
const track = make(true);
const show = (...texts: string[]) => cuesOn(track, texts);
const addTrack = (own: boolean) => {
const added = make(own);
act(() => {
tracks.dispatchEvent(new Event("addtrack"));
});
return (...texts: string[]) => cuesOn(added, texts);
};
return { video, show, addTrack };
}
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
* the player is drawn that much larger than its own pixels, as the windowed player is. */
function layout(
video: HTMLVideoElement,
bar: HTMLElement,
textBottom: number,
barTop: number,
scale = 1,
) {
const rect = (top: number, bottom: number) =>
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
if (this === video) return rect(0, 1000);
if (this === bar) return rect(barTop, 1000);
// The outer box: the lift moves only the text inside it.
if (isBox(this)) return rect(textBottom - 80, textBottom);
return rect(0, 0);
});
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return isBox(this) ? 80 / scale : 0;
});
}
const lifted = () => screen.getByTestId("plex-subtitles-text");
function mount(video: HTMLVideoElement, avoid: boolean) {
const bar = document.createElement("div");
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
barRef.current = bar;
const props = {
video,
pos: 88,
size: 100,
color: "#ffffff",
background: "transparent",
shadowPx: 0,
shadowColor: "#000000",
avoidRef: barRef,
};
const view = render(<PlexSubtitles {...props} avoid={avoid} />);
return { bar, rerender: (a: boolean) => view.rerender(<PlexSubtitles {...props} avoid={a} />) };
}
describe("PlexSubtitles", () => {
it("shows the cues the browser reports active, at the cue type size of the video box", () => {
const { video, show } = fakeVideo();
mount(video, false);
show("Half full", "or half empty?");
const box = screen.getByTestId("plex-subtitles");
expect(box.textContent).toBe("Half fullor half empty?");
expect(box.style.fontSize).toBe("50px");
expect(box.style.top).toBe("88%");
});
it("rises above the control bar while it shows, and settles back when it hides", () => {
const { video, show } = fakeVideo();
const { bar, rerender } = mount(video, false);
layout(video, bar, 950, 900);
show("Half full");
expect(lifted().style.transform).toBe("translateY(0px)");
rerender(true);
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
expect(lifted().style.transform).toBe("translateY(-58px)");
rerender(false);
expect(lifted().style.transform).toBe("translateY(0px)");
});
it("lifts in the player's own pixels when the player is drawn scaled", () => {
const { video, show } = fakeVideo();
const { bar } = mount(video, true);
layout(video, bar, 950, 900, 1.25);
show("Half full");
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
expect(lifted().style.transform).toBe("translateY(-46px)");
});
it("places a cue that just appeared, and slides only one already on screen", () => {
const { video, show } = fakeVideo();
const { bar, rerender } = mount(video, true);
layout(video, bar, 950, 900);
show("Half full");
expect(lifted().style.transform).toBe("translateY(-58px)");
expect(lifted().className).not.toContain("transition-transform");
rerender(false);
expect(lifted().className).toContain("transition-transform");
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
show();
rerender(true);
show("or half empty?");
expect(lifted().style.transform).toBe("translateY(-58px)");
expect(lifted().className).not.toContain("transition-transform");
});
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
const { video, addTrack } = fakeVideo();
mount(video, false);
const showOnNew = addTrack(true);
showOnNew("After the seek");
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
});
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
const { video, addTrack } = fakeVideo();
mount(video, false);
const showInBand = addTrack(false);
showInBand("Embedded");
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
});
it("draws nothing while no cue is active", () => {
const { video } = fakeVideo();
mount(video, true);
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { subShadow } from "../lib/plexPlayerPrefs";
import { ownTextTracks } from "../lib/plexSubTracks";
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
// this layer shows them. The browser's own rendering could not move out of the way of the control
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
// settings style it directly.
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
const CUE_EM_OF_HEIGHT = 0.05;
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
const CLEARANCE_PX = 8;
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
const [cues, setCues] = useState<Cue[]>([]);
useEffect(() => {
if (!video) return;
const tracks = video.textTracks;
const read = () => {
const now: Cue[] = [];
const own = ownTextTracks(video);
for (const tr of Array.from(tracks)) {
if (!own.has(tr) || tr.mode === "disabled") continue;
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
}
setCues((prev) =>
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
);
};
const bound = new Set<TextTrack>();
const bind = (tr: TextTrack) => {
if (bound.has(tr)) return;
bound.add(tr);
tr.addEventListener("cuechange", read);
};
const onAdd = () => {
for (const tr of Array.from(tracks)) bind(tr);
read();
};
for (const tr of Array.from(tracks)) bind(tr);
tracks.addEventListener("addtrack", onAdd);
tracks.addEventListener("removetrack", read);
read();
return () => {
tracks.removeEventListener("addtrack", onAdd);
tracks.removeEventListener("removetrack", read);
for (const tr of bound) tr.removeEventListener("cuechange", read);
};
}, [video]);
return cues;
}
/** The video box's height, followed as the window or fullscreen changes it. */
function useHeight(el: HTMLElement | null): number {
const [height, setHeight] = useState(0);
useEffect(() => {
if (!el) return;
const measure = () => setHeight(el.clientHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [el]);
return height;
}
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
* markup from a string, so nothing in a subtitle file can run. */
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
const ref = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
else el.textContent = cue.text ?? "";
}, [cue]);
return (
<span
ref={ref}
style={style}
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
/>
);
}
export default function PlexSubtitles({
video,
pos,
size,
color,
background,
shadowPx,
shadowColor,
avoid,
avoidRef,
}: {
video: HTMLVideoElement | null;
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
pos: number;
size: number;
color: string;
background: string;
shadowPx: number;
shadowColor: string;
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
avoid: boolean;
avoidRef: RefObject<HTMLElement | null>;
}) {
const cues = useActiveCues(video);
const height = useHeight(video);
const boxRef = useRef<HTMLDivElement>(null);
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
// not slid there from where the previous cue was left.
const [lift, setLift] = useState({ px: 0, slide: false });
const shownRef = useRef(false);
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
// by that scale.
useLayoutEffect(() => {
const box = boxRef.current;
if (!box || !video || cues.length === 0) {
shownRef.current = false;
return;
}
const rect = box.getBoundingClientRect();
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
setLift({ px, slide: shownRef.current });
shownRef.current = true;
}, [avoid, avoidRef, cues, height, pos, size, video]);
if (cues.length === 0 || height === 0) return null;
const text: React.CSSProperties = {
color,
backgroundColor: background,
textShadow: subShadow(shadowPx, shadowColor),
};
return (
<div
ref={boxRef}
data-testid="plex-subtitles"
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
>
<div
data-testid="plex-subtitles-text"
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
style={{ transform: `translateY(${-lift.px}px)` }}
>
{cues.map((cue, i) => (
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
))}
</div>
</div>
);
}
+35 -3
View File
@@ -4,8 +4,8 @@
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import SearchBar from "./SearchBar";
vi.mock("react-i18next", async (importOriginal) => {
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
describe("SearchBar responsive wrapper", () => {
it("carries the container-query classes that make it wrap instead of collapsing", () => {
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const cls = (container.firstElementChild as HTMLElement).className;
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
expect(cls).toContain("@max-[640px]/appmain:basis-full");
expect(cls).toContain("@max-[640px]/appmain:order-last");
});
});
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
describe("SearchBar on a touch screen", () => {
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
const { container, getByRole } = render(
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
);
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
"pointer-coarse:hidden",
);
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
expect(pill.className).toContain("pointer-coarse:h-13");
});
it("shortens Go to its icon by the column's width, not the screen's", () => {
const { getByText } = render(
<SearchBar
value=""
onChange={() => {}}
placeholder="Search…"
onGo={() => {}}
goIcon={<svg />}
goLabel="Go far"
/>,
);
const label = getByText("Go far");
expect(label.className).toContain("@min-[900px]/appmain:inline");
expect(label.className).not.toMatch(/(^|\s)md:inline/);
});
});
+10 -5
View File
@@ -72,14 +72,18 @@ export default function SearchBar({
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
return (
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
44 pill sat on its edge. */}
<div
className={clsx(
fieldClass({ size: "free", pill: true, floating: true }),
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
)}
>
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
@@ -116,7 +120,8 @@ export default function SearchBar({
title={goTitle ?? goLabel ?? t("header.goTip")}
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</Button>
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { PopoverItem, PopoverList } from "./AnchoredPopover";
import { IconButton } from "./IconButton";
import { Input } from "./Input";
import { MenuCheck } from "./MenuCheck";
import { StateMessage } from "./QueryState";
// The body of a "which of these is it in" menu: a checkbox row per set (a playlist, a collection, a
// tag), and a field that names a new one. Rendered inside an anchored `menu` surface — the video
// card's "Add to playlist", a Plex title's "Add to collection", a channel's tags. One body, so a fix
// to its states or keys reaches all three: the copies had drifted (one said a failed load as "none").
//
// The surface around it is opened with `hostsInput: true` (no typeahead, no focus on hover), and the
// name field keeps its keys to itself — except Escape, which closes the menu, and the up/down arrows,
// which step from the field into the rows: the rows may arrive after the surface opened, when the
// popover's own first-row focus has already given up and left the focus in the field.
//
// The surface is opened with `listInside` too: the menu role sits on the rows' own box, and the lines
// about them (loading, a failed load with its Retry, none yet), the heading and the field sit beside
// it — a menu may own its items only. And when the state line swaps for the rows (or back), focus that
// was on its Retry would be left on <body> with the panel still open, the arrows dead: it is put back
// in the panel — on the first row, else the field, else the new Retry. A spot it was put on only
// because the rows were not there yet (the field, while a retry loads) hands on to the first row.
export interface MembershipRow {
id: string | number;
label: string;
checked: boolean;
/** A mark after the label (the YouTube glyph on a linked playlist). */
extra?: ReactNode;
}
export function MembershipMenu({
heading,
rows,
onToggle,
status = "ready",
onRetry,
emptyText,
errorText,
busy = false,
create,
dense = false,
}: {
heading?: string;
rows: MembershipRow[];
onToggle: (row: MembershipRow) => void;
/** The rows' load: `loading` and `error` say so instead of an empty list. */
status?: "loading" | "error" | "ready";
onRetry?: () => void;
emptyText: string;
errorText: string;
/** A write is in flight: said on the list (`aria-busy`). The rows stay enabled — a disabled button
* blurs the focused row to <body> mid-request and ends roving focus; the caller no-ops instead. */
busy?: boolean;
create?: {
placeholder: string;
ariaLabel?: string;
maxLength?: number;
/** With a label, a trailing button submits too; without, a leading + marks the field. */
submitLabel?: string;
/** Resolves true when the field should clear (the new set was made). */
onCreate: (name: string) => boolean | Promise<boolean>;
};
/** The smaller rows of a menu inside a table cell (a channel's tags). */
dense?: boolean;
}) {
const { t } = useTranslation();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const listRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const [draft, setDraft] = useState("");
const shownStatus = useRef(status);
const placed = useRef<HTMLElement | null>(null);
useEffect(() => {
if (shownStatus.current === status) return;
shownStatus.current = status;
// A frame later: rows that just mounted join the menu's list a render after this effect, and a
// row focused before that is not known to it — the arrows then did nothing from it.
const frame = requestAnimationFrame(() => {
const root = rootRef.current;
const at = document.activeElement;
// …unless a name is being typed into it meanwhile.
const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value);
const lost = at == null || at === document.body || parked;
if (!root || !lost) return;
const target =
root.querySelector<HTMLElement>("[role=menuitemcheckbox]") ??
root.querySelector<HTMLElement>("input") ??
root.querySelector<HTMLElement>("button");
target?.focus();
placed.current = target;
});
return () => cancelAnimationFrame(frame);
}, [status]);
async function submit() {
const name = draft.trim();
if (!name) return;
if (await create?.onCreate(name)) setDraft("");
}
function intoRows(key: string) {
const items = listRef.current?.querySelectorAll<HTMLElement>("[role=menuitemcheckbox]");
const row = key === "ArrowDown" ? items?.[0] : items?.[items.length - 1];
row?.focus();
}
const setList = (node: HTMLDivElement | null) => {
listRef.current = node;
fadeRef(node);
};
const rowText = dense ? "text-xs px-2" : "text-sm px-1.5";
return (
// A portal still bubbles through the React tree: keep a click here off the card that opened it.
<div ref={rootRef} className="flex flex-col" onClick={(e) => e.stopPropagation()}>
{heading && <div className="text-xs text-muted px-1.5 pb-1.5">{heading}</div>}
<div
ref={setList}
style={fadeStyle}
aria-busy={busy || status === "loading"}
className={`${dense ? "max-h-60" : "max-h-56"} overflow-y-auto no-scrollbar`}
>
{status === "loading" ? (
<StateMessage compact>{t("common.loading")}</StateMessage>
) : status === "error" ? (
<StateMessage compact tone="error" onRetry={onRetry}>
{errorText}
</StateMessage>
) : rows.length === 0 ? (
<p className={`text-xs text-muted py-2 ${dense ? "px-2" : "px-1.5"}`}>{emptyText}</p>
) : null}
<PopoverList>
{status === "ready" &&
rows.map((row) => (
<PopoverItem
key={row.id}
// A thing can sit in several sets at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={row.checked}
onSelect={() => onToggle(row)}
label={row.label}
className={`w-full flex items-center gap-2 ${rowText} py-1.5 rounded-lg outline-hidden transition ${
dense && !row.checked ? "text-muted" : "text-fg"
}`}
>
<MenuCheck kind="many" on={row.checked} />
<span className="truncate flex-1 text-left">{row.label}</span>
{row.extra}
</PopoverItem>
))}
</PopoverList>
</div>
{create && (
<div
className={`flex items-center border-t border-border ${dense ? "gap-1 mt-1" : "gap-1.5 mt-1.5"} pt-1.5`}
>
{!create.submitLabel && <Plus className="w-3.5 h-3.5 text-muted shrink-0" />}
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
intoRows(e.key);
} else if (e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
maxLength={create.maxLength}
placeholder={create.placeholder}
aria-label={create.ariaLabel}
/>
{create.submitLabel && (
<IconButton
variant="outline"
onClick={() => void submit()}
disabled={!draft.trim()}
label={create.submitLabel}
>
<Plus />
</IconButton>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
@@ -64,3 +64,41 @@ describe("an open tooltip", () => {
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
});
});
// On an iPad a tap's synthetic mouseenter showed the caption; WebKit then took the tap as a hover and
// swallowed its click (every rail button needed two taps), and with no mouseleave the caption stayed.
describe("a tooltip under a finger", () => {
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "";
}
}
const hover = (trigger: HTMLElement, pointerType: string) =>
act(() => {
trigger.dispatchEvent(new PointerEventStub("pointerdown", { bubbles: true, pointerType }));
trigger.dispatchEvent(new PointerEventStub("pointerenter", { pointerType }));
fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);
});
const mount = () => {
render(
<Tooltip hint="A hint" always>
<button>icon</button>
</Tooltip>,
);
return screen.getByText("icon").parentElement as HTMLElement;
};
it("does not open from a tap", async () => {
hover(mount(), "touch");
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("still opens under a mouse", async () => {
hover(mount(), "mouse");
expect(await screen.findByRole("tooltip")).toBeTruthy();
});
});
+2
View File
@@ -37,6 +37,8 @@ export default function Tooltip({
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
hoverDelay: { open: openDelay, close: 0 },
// A caption is for a mouse (and a key's focus). Opened by a tap it cost touch the tap itself.
mouseHoverOnly: true,
});
const { setReference } = pop.refs;
@@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions {
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
* and a tap is the card's play. */
pointerOnly?: boolean;
/** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a
* control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that
* first tap as a hover and swallowed its click (every rail button needed two taps), and with no
* mouseleave from a finger the caption stayed up. */
mouseHoverOnly?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
move: false,
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
mouseOnly: !isHover || !!opts.pointerOnly,
mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly,
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
// menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
+1 -1
View File
@@ -62,7 +62,7 @@ export function trapTab(
// second copy of the lock is gone.
let scrollLocks = 0;
let unlockedOverflow = "";
function lockBodyScroll(): () => void {
export function lockBodyScroll(): () => void {
if (scrollLocks++ === 0) {
unlockedOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
+38 -4
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
import { pinPlex, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
@@ -46,15 +49,27 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
const copy = useCopyItems();
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
// own page).
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
// only a leaf is added from a card.
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
* without one the row is left out rather than opening the panel at a corner. Required, so every
* host menu decides: an optional one let a menu that has an anchor drop the row silently. */
const items = (
c: PlexCard,
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
{
play = true,
pin: withPin = true,
anchor,
}: { play?: boolean; pin?: boolean; anchor: Element | null },
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
@@ -104,6 +119,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
},
]
: []),
// A movie or a show can be a collection member; an episode cannot.
...(isAdmin && anchor && c.type !== "episode"
? [
{
id: "collection",
label: t("plex.collectionAdd.row"),
icon: <Layers className={icon} />,
separatorBefore: !playable,
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
},
]
: []),
...(playable && onInfo
? [
{
@@ -120,7 +147,14 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
];
};
const panel = adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
const panel = (
<>
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
{collecting && (
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
)}
</>
);
return { items, add, panel };
}
@@ -1,4 +1,16 @@
[
{
"version": "0.103.0",
"date": "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."
]
},
{
"version": "0.102.0",
"date": "2026-10-08",
@@ -1,4 +1,16 @@
[
{
"version": "0.103.0",
"date": "2026-10-08",
"new": [
"Plex: egy film vagy sorozat menüjében a „Hozzáadás gyűjteményhez…” bepipálja a címet a gyűjteményeidbe vagy kiveszi belőlük, illetve új gyűjteményt hoz létre vele — egy lépésben, a Gyűjtemények oldal megkerülése nélkül."
],
"fixed": [
"Plex-lejátszó: a felirat felemelkedik a vezérlősáv fölé, amíg az látszik, és visszacsúszik, amikor eltűnik, így a sáv sosem takarja el — iPaden sem.",
"iPad: a fejléc keresője illik az érintőképernyőhöz, a súgók nem ragadnak be, a Plex-lejátszó oldala nem görget és nincs kék sáv, a teljes képernyő gombja pedig rejtve marad, ahol a böngésző nem tud valódi teljes képernyőt.",
"A lejátszási lista, gyűjtemény és címke menük kiírják, ha a lista töltődik vagy nem töltődött be (újrapróbálással), nem mutatják üresnek, és billentyűzettel is kezelhetők. Továbbá kisebb javítások a YouTube-lejátszó címsorában."
]
},
{
"version": "0.102.0",
"date": "2026-10-08",
+6
View File
@@ -384,5 +384,11 @@
"byGenre": "Browse by genre",
"becauseWatched": "Because you watched {{title}}",
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
},
"collectionAdd": {
"row": "Add to collection…",
"title": "Add to collection",
"none": "No collection to add to yet — name one below.",
"loadFailed": "Couldn't load the collections"
}
}
+6
View File
@@ -384,5 +384,11 @@
"byGenre": "Böngészés műfaj szerint",
"becauseWatched": "Mert megnézted: {{title}}",
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
},
"collectionAdd": {
"row": "Kollekcióhoz adás…",
"title": "Kollekcióhoz adás",
"none": "Még nincs kollekció, amihez adhatnád – adj meg egy nevet lent.",
"loadFailed": "Nem sikerült betölteni a kollekciókat"
}
}
+6
View File
@@ -508,6 +508,12 @@ export const plexApi = {
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// A movie or show card's "Add to collection": its library's manual collections, each saying whether
// the title is in it already.
plexCollectionsFor: (
itemRk: string,
): Promise<{ collections: (PlexCollection & { contains: boolean })[] }> =>
req(`/api/plex/collections/for/${encodeURIComponent(itemRk)}`),
// One collection's hero + its members in the requested order (the Collection info page). The sort
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
+17 -2
View File
@@ -8,7 +8,7 @@ import {
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// Safari and iPadOS before 16.4 ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
@@ -29,11 +29,26 @@ export function fullscreenElement(): Element | null {
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Can this browser put a plain element in fullscreen at all? Read off `Element.prototype`, never
* `document.fullscreenEnabled`: Brave on iPadOS switches element fullscreen off in its web view, then
* patches `HTMLElement.prototype.requestFullscreen` with a stand-in that only fullscreens a <video>
* and forces `fullscreenEnabled` to true — both claim a support that is not there, and the players'
* fullscreen buttons did nothing. A button that cannot work is not shown (owner). */
export function canFullscreenElements(): boolean {
if (typeof Element === "undefined") return false;
const proto = Element.prototype as Partial<WebkitElement>;
return (
typeof proto.requestFullscreen === "function" ||
typeof proto.webkitRequestFullscreen === "function"
);
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
// Wrapped: a patched stand-in (Brave's, above) may return a boolean, not a promise.
if (e.requestFullscreen) void Promise.resolve(e.requestFullscreen()).catch(() => {});
else e.webkitRequestFullscreen?.();
}
+42
View File
@@ -0,0 +1,42 @@
// hls.js empties the cues of every text track on the <video> when a manifest loads or the media
// attaches — the Plex player's own subtitle <track> included, so a VTT that loaded first showed
// nothing for the rest of the session. Its subtitle and caption controllers are not built at all.
import { describe, expect, it, vi } from "vitest";
const built: Record<string, unknown>[] = [];
vi.mock("hls.js", () => {
class FakeHls {
static isSupported = () => true;
static Events = {};
static ErrorTypes = {};
constructor(config: Record<string, unknown>) {
built.push(config);
}
on() {}
}
return { default: FakeHls };
});
const { createHlsPort } = await import("./hlsPort");
describe("createHlsPort", () => {
it("builds hls.js without the controllers that clear every text track's cues", () => {
const noop = () => {};
createHlsPort({
onManifestParsed: noop,
onAudioTracksUpdated: noop,
onFragLoaded: noop,
onError: noop,
});
const config = built[0] ?? {};
for (const key of [
"subtitleTrackController",
"subtitleStreamController",
"timelineController",
]) {
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
expect(key in config).toBe(true);
expect(config[key]).toBeUndefined();
}
});
});
+13 -1
View File
@@ -12,7 +12,19 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
//
// No subtitle or caption controllers (a controller left unset is never built): the subtitles are
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
// the manifest showed nothing for the rest of the session.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
subtitleTrackController: undefined,
subtitleStreamController: undefined,
timelineController: undefined,
});
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
@@ -42,7 +42,6 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
detail: detail(),
sessionK,
subOffset: 0,
subPos: 88,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -316,7 +315,6 @@ function TrackHarness() {
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -417,7 +415,6 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
restartSession: (at) => restart(id, at),
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -439,3 +436,98 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
expect(restart).not.toHaveBeenCalled();
});
});
// The browser no longer draws the cues: its rendering sat under the control bar on an iPad, and a cue
// placed by `line` cannot move out of the way. The track stays loaded, as `hidden`, for PlexSubtitles.
describe("usePlexTracks — who draws the cues", () => {
/** A <video> whose text-track list fires `addtrack` / `change` like a browser's. Each mode is a
* track of the player's own <track>; `mediaAdds` adds one from the media itself instead. */
function videoWithTracks(...modes: TextTrackMode[]) {
const list: { kind: string; mode: TextTrackMode }[] = [];
const tracks = Object.assign(new EventTarget(), {
get length() {
return list.length;
},
[Symbol.iterator]: () => list[Symbol.iterator](),
});
const video = document.createElement("video");
Object.defineProperty(video, "textTracks", { value: tracks });
const add = (mode: TextTrackMode, own: boolean) => {
const track = { kind: "subtitles", mode };
if (own) {
const el = document.createElement("track");
Object.defineProperty(el, "track", { value: track });
video.appendChild(el);
}
list.push(track);
};
for (const mode of modes) add(mode, true);
// What a browser does with a freshly mounted `default` <track>: it switches it on by itself.
const browserAddsShowing = () =>
act(() => {
add("showing", true);
tracks.dispatchEvent(new Event("addtrack"));
});
const browserShows = () =>
act(() => {
list[0]!.mode = "showing";
tracks.dispatchEvent(new Event("change"));
});
// A direct-played file's own subtitle stream, which WebKit lists — and may switch on.
const mediaAdds = (mode: TextTrackMode) =>
act(() => {
add(mode, false);
tracks.dispatchEvent(new Event("addtrack"));
});
return { video, list, browserAddsShowing, browserShows, mediaAdds };
}
function mountOn(video: HTMLVideoElement) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client()}>{children}</QueryClientProvider>
);
return renderHook(
() =>
usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: { current: video },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 0,
}),
{ wrapper },
);
}
it("keeps the mounted track hidden: loaded and tracked, not drawn by the browser", () => {
const { video, list } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
expect(list[0]!.mode).toBe("hidden");
unmount();
});
it("hides a remounted track the browser switched on by itself", () => {
const { video, list, browserAddsShowing, browserShows } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
browserAddsShowing();
expect(list.map((t) => t.mode)).toEqual(["hidden", "hidden"]);
browserShows();
expect(list[0]!.mode).toBe("hidden");
unmount();
});
it("switches off a subtitle track that is not the player's own, and leaves its own hidden", () => {
const { video, list, mediaAdds } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
mediaAdds("hidden");
mediaAdds("showing");
expect(list.map((t) => t.mode)).toEqual(["hidden", "disabled", "disabled"]);
unmount();
});
});
+21 -37
View File
@@ -18,6 +18,7 @@ import { qk } from "./queryKeys";
import { useLatest } from "./useLatest";
import i18n, { plexLang } from "../i18n";
import { markerSkip } from "./plexMarkers";
import { ownTextTracks } from "./plexSubTracks";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -585,7 +586,6 @@ export function usePlexTracks(opts: {
detail: PlexItemDetail | undefined;
sessionK: number;
subOffset: number;
subPos: number;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
@@ -633,8 +633,7 @@ export function usePlexTracks(opts: {
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
opts;
const { id, detail, sessionK, subOffset, restartSession, setAudioTrack, patchPrefs } = opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
@@ -854,47 +853,32 @@ export function usePlexTracks(opts: {
// sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
// Keep our own track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
// browser. Any other subtitle track (a direct-played file's own, see ownTextTracks) is switched
// off, so neither the browser nor PlexSubtitles draws it. Not once per selection: the <track> is
// remounted on its own key (a grown window, a retry), and the browser switches a fresh `default`
// track to "showing" a task later — which drew the cues a second time, natively, under ours. So it
// is re-applied on every track the list gains and on every mode change the browser makes.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
}, [subOrd, id, subTrackOffset, videoRef]);
// Position subtitle cues per the user's vertical setting (subPos). The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = subPos;
} catch {
/* not a positionable cue */
}
}
const tracks = video.textTracks;
const settle = () => {
const own = ownTextTracks(video);
for (const tr of Array.from(tracks)) {
const subtitle = tr.kind === "subtitles" || tr.kind === "captions";
const want = own.has(tr) ? "hidden" : subtitle ? "disabled" : tr.mode;
if (tr.mode !== want) tr.mode = want;
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
placeAll();
settle();
tracks.addEventListener("addtrack", settle);
tracks.addEventListener("change", settle);
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
tracks.removeEventListener("addtrack", settle);
tracks.removeEventListener("change", settle);
};
}, [id, subPos, videoRef]);
}, [subOrd, id, videoRef]);
return {
audioOrd,
+4 -4
View File
@@ -78,9 +78,9 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
// snow-white frame. Empty string when the slider is 0 (shadow off).
export function subShadowCss(px: number, color: string): string {
if (px <= 0) return "";
// snow-white frame. A `text-shadow` value; undefined when the slider is 0 (shadow off).
export function subShadow(px: number, color: string): string | undefined {
if (px <= 0) return undefined;
const o = px;
const b = Math.max(1, Math.round(px / 2));
const dirs: [number, number][] = [
@@ -94,7 +94,7 @@ export function subShadowCss(px: number, color: string): string {
[-o, -o],
];
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
return `${outline},0 0 ${o * 2}px ${color}`;
}
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
+6
View File
@@ -0,0 +1,6 @@
/** The text tracks of the Plex player's own <track> elements. A <video> can expose others too: the
* subtitle stream of a direct-played file (WebKit lists an mp4's mov_text in `textTracks`), or a
* metadata track hls.js adds. Those are neither shown nor drawn: the viewer picks the subtitles. */
export function ownTextTracks(video: HTMLVideoElement): Set<TextTrack> {
return new Set(Array.from(video.querySelectorAll("track"), (el) => el.track));
}
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { useScrollFade } from "./useScrollFade";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
function Scroller({ fadeTop = true }: { fadeTop?: boolean }) {
const fade = useScrollFade(20, fadeTop);
const { ref } = fade;
return <div data-testid="s" ref={ref} style={fade.style} />;
}
/** A scroller 100 px tall over 300 px of content, scrolled to `top`. */
function scrolledTo(el: HTMLElement, top: number) {
Object.defineProperties(el, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 300 },
scrollTop: { configurable: true, value: top, writable: true },
});
fireEvent.scroll(el);
}
// iPad WebKit kept a scrolled container's mask stuck to the content: at the bottom of a scrolled page
// the last strip (a table's pager) sat under a dark band. With no edge to fade there is no mask.
describe("useScrollFade", () => {
it("masks the edge that is clipped", () => {
const { getByTestId } = render(<Scroller />);
const el = getByTestId("s");
scrolledTo(el, 0);
expect(el.style.maskImage).toContain("calc(100% - 20px)");
});
it("sets no mask once nothing fades, and keeps the layer it made", () => {
const { getByTestId } = render(<Scroller fadeTop={false} />);
const el = getByTestId("s");
scrolledTo(el, 200);
expect(el.style.maskImage).toBe("");
expect(el.style.isolation).toBe("isolate");
});
});
+6 -1
View File
@@ -18,7 +18,12 @@ export function useScrollFade(
style: CSSProperties;
} {
const { ref, start, end } = useScrollEdges("y");
const mask = `linear-gradient(to bottom, transparent, #000 ${start && fadeTop ? fadePx : 0}px, #000 calc(100% - ${
// No edge to fade, no mask at all: iPad WebKit kept a scrolled container's mask stuck to the
// content, so at the bottom of a scrolled page the last strip (a table's pager) sat under a dark
// band. `isolation` keeps the stacking context the mask made, so nothing inside changes layer.
const top = start && fadeTop;
if (!top && !end) return { ref, style: { isolation: "isolate" } };
const mask = `linear-gradient(to bottom, transparent, #000 ${top ? fadePx : 0}px, #000 calc(100% - ${
end ? fadePx : 0
}px), transparent)`;
return { ref, style: { maskImage: mask, WebkitMaskImage: mask } };