Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f4efde98 | ||
|
|
c0059a0c4b | ||
|
|
a29cadf03e | ||
|
|
83886494ce | ||
|
|
4eaa55385f | ||
|
|
7541b1b016 | ||
|
|
24ad17fc7d | ||
|
|
48bd038c5f | ||
|
|
0fce732497 | ||
|
|
7e94da72b9 | ||
|
|
fadb4f17a9 | ||
|
|
57a81c5643 | ||
|
|
dae0b80f6a | ||
|
|
75093458c9 | ||
|
|
5db6dc8ee6 | ||
|
|
45a9ceae25 | ||
|
|
5249c41edc | ||
|
|
c3664826fe | ||
|
|
c9b8fe15a3 | ||
|
|
1044eaf8a9 | ||
|
|
ca949d22de | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 |
@@ -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
|
||||
|
||||
@@ -167,12 +167,16 @@ class PlexClient:
|
||||
keys = ",".join(str(k) for k in rating_keys)
|
||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
||||
|
||||
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
|
||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
||||
def create_collection(
|
||||
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
|
||||
) -> dict:
|
||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
|
||||
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
|
||||
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
|
||||
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||
mc = self._write(
|
||||
"POST",
|
||||
f"/library/collections?type=1&smart=0§ionId={section_key}"
|
||||
f"/library/collections?type={item_type}&smart=0§ionId={section_key}"
|
||||
f"&title={quote(title, safe='')}&uri={uri}",
|
||||
)
|
||||
items = mc.get("Metadata", []) or []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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" |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user