Compare commits
23
Commits
20178f0ec4
...
75f4efde98
| 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).
|
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
|
## 0.102.0 — 2026-10-08
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -167,12 +167,16 @@ class PlexClient:
|
|||||||
keys = ",".join(str(k) for k in rating_keys)
|
keys = ",".join(str(k) for k in rating_keys)
|
||||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{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:
|
def create_collection(
|
||||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
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="")
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||||
mc = self._write(
|
mc = self._write(
|
||||||
"POST",
|
"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}",
|
f"&title={quote(title, safe='')}&uri={uri}",
|
||||||
)
|
)
|
||||||
items = mc.get("Metadata", []) or []
|
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):
|
class Member(NamedTuple):
|
||||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
"""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
|
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
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise refused(400, "collection_fields_required")
|
raise refused(400, "collection_fields_required")
|
||||||
|
seed_show = by_rating_key(db, PlexShow, seed)
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# The "New collection" flow only knows the seed title → derive its library.
|
# 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
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise not_found("library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
|
new_rk = ""
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
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
|
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
|
col.editable = True
|
||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
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
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
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 plex.seen[-1].method == "POST"
|
||||||
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
||||||
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
|
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"] = {}
|
plex.routes["/library/collections"] = {}
|
||||||
with pytest.raises(PlexError):
|
with pytest.raises(PlexError):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||||
from app.plex.client import PlexError
|
from app.plex.client import PlexError
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex import collections as coll_routes
|
from app.routes.plex import collections as coll_routes
|
||||||
|
|
||||||
|
|
||||||
@@ -224,8 +226,9 @@ class _CollStub:
|
|||||||
if self.fail_on == name:
|
if self.fail_on == name:
|
||||||
raise PlexError("boom")
|
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._maybe("create_collection")
|
||||||
|
self.created_type = item_type
|
||||||
return {"ratingKey": "NEW"}
|
return {"ratingKey": "NEW"}
|
||||||
|
|
||||||
def add_collection_item(self, rk, item):
|
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
|
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):
|
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 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".
|
# 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.status_code == 422, r.text
|
||||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||||
assert "create_collection" in stub.calls
|
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):
|
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 | 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-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-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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { ListPlus, Plus } from "lucide-react";
|
import { ListPlus } from "lucide-react";
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
// 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
|
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||||
// 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.
|
|
||||||
export default function AddToPlaylist({
|
export default function AddToPlaylist({
|
||||||
videoId,
|
videoId,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
@@ -94,9 +90,7 @@ function MembershipPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const open = pop.open;
|
const open = pop.open;
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const membership = useQuery({
|
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
|
// 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
|
// 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) {
|
async function toggle(pl: Playlist) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||||
else await api.addToPlaylist(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) {
|
} catch (e) {
|
||||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||||
}
|
}
|
||||||
@@ -129,19 +127,20 @@ function MembershipPanel({
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd() {
|
async function createAndAdd(name: string) {
|
||||||
const name = newName.trim();
|
if (busy) return false;
|
||||||
if (!name || busy) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let made = false;
|
||||||
try {
|
try {
|
||||||
const pl = await api.createPlaylist(name);
|
const pl = await api.createPlaylist(name);
|
||||||
await api.addToPlaylist(pl.id, videoId);
|
await api.addToPlaylist(pl.id, videoId);
|
||||||
setNewName("");
|
made = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("playlists.addFailed"));
|
notifyActionError(e, t("playlists.addFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
return made;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
||||||
@@ -149,67 +148,40 @@ function MembershipPanel({
|
|||||||
const lists = membership.data ?? [];
|
const lists = membership.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PopoverSurface
|
||||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
popover={pop}
|
||||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
className={`${SURFACE.popover} w-60 p-2`}
|
||||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
ariaLabel={t("playlists.addToPlaylistTitle")}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
listInside
|
||||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
>
|
||||||
{t("playlists.addToPlaylistTitle")}
|
<MembershipMenu
|
||||||
</div>
|
heading={t("playlists.addToPlaylistTitle")}
|
||||||
<div
|
rows={lists.map((pl) => ({
|
||||||
ref={fadeRef}
|
id: pl.id,
|
||||||
style={fadeStyle}
|
label: playlistName(pl, t),
|
||||||
aria-busy={busy}
|
checked: !!pl.has_video,
|
||||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
extra:
|
||||||
>
|
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||||
{lists.length === 0 && !membership.isLoading && (
|
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
||||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
) : undefined,
|
||||||
)}
|
}))}
|
||||||
{lists.map((pl) => (
|
onToggle={(row) => {
|
||||||
<PopoverItem
|
const pl = lists.find((p) => p.id === row.id);
|
||||||
key={pl.id}
|
if (pl) void toggle(pl);
|
||||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
}}
|
||||||
role="menuitemcheckbox"
|
status={
|
||||||
checked={pl.has_video}
|
membership.isError && !membership.data
|
||||||
onSelect={() => toggle(pl)}
|
? "error"
|
||||||
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
: membership.isLoading
|
||||||
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
? "loading"
|
||||||
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
: "ready"
|
||||||
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
}
|
||||||
label={playlistName(pl, t)}
|
onRetry={() => void membership.refetch()}
|
||||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
emptyText={t("playlists.noneYet")}
|
||||||
>
|
errorText={t("common.loadError")}
|
||||||
<MenuCheck kind="many" on={!!pl.has_video} />
|
busy={busy}
|
||||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
|
||||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
/>
|
||||||
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
</PopoverSurface>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useRef, useState } from "react";
|
||||||
import { Plus, Sparkles } from "lucide-react";
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../lib/api";
|
||||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { IconButton } from "./ui/IconButton";
|
|
||||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||||
import { chipClass } from "./ui/chipStyles";
|
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";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
// 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
|
/** 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
|
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||||
* detail page in Part 2; internal for now.) */
|
* `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({
|
function TagEditorMenu({
|
||||||
userTags,
|
userTags,
|
||||||
attachedIds,
|
attachedIds,
|
||||||
@@ -208,71 +207,27 @@ function TagEditorMenu({
|
|||||||
onCreate: (name: string) => void;
|
onCreate: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState("");
|
const tags = useTags();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const attached = new Set(attachedIds);
|
const attached = new Set(attachedIds);
|
||||||
const submit = () => {
|
|
||||||
const name = draft.trim();
|
|
||||||
if (!name) return;
|
|
||||||
onCreate(name);
|
|
||||||
setDraft("");
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<MembershipMenu
|
||||||
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
|
dense
|
||||||
{userTags.map((tg) => {
|
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||||
const on = attached.has(tg.id);
|
onToggle={(row) => onToggle(row.id as number)}
|
||||||
return (
|
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||||
<PopoverItem
|
onRetry={() => void tags.refetch()}
|
||||||
key={tg.id}
|
emptyText={t("channels.tags.noneYet")}
|
||||||
// Several tags can be on at once — a checkbox set, not one exclusive choice.
|
errorText={t("common.loadError")}
|
||||||
role="menuitemcheckbox"
|
create={{
|
||||||
checked={on}
|
placeholder: t("channels.tags.createPlaceholder"),
|
||||||
onSelect={() => onToggle(tg.id)}
|
maxLength: 64,
|
||||||
label={tg.name}
|
submitLabel: t("channels.tags.create"),
|
||||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
onCreate: (name) => {
|
||||||
on ? "text-fg" : "text-muted"
|
onCreate(name);
|
||||||
}`}
|
return true;
|
||||||
>
|
},
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={editPop}
|
popover={editPop}
|
||||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||||
|
listInside
|
||||||
>
|
>
|
||||||
<TagEditorMenu
|
<TagEditorMenu
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function useHomeCardMenu({
|
|||||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.plex
|
: target.plex
|
||||||
? plexActions
|
? 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))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.kind === "channel" || target.kind === "playlist"
|
: target.kind === "channel" || target.kind === "playlist"
|
||||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function useCap(): string {
|
|||||||
// words matched nothing.
|
// words matched nothing.
|
||||||
|
|
||||||
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
/** 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() {
|
export function PaletteKeycap() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cap = useCap();
|
const cap = useCap();
|
||||||
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
|
|||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label={`${t("palette.open")} (${cap})`}
|
aria-label={`${t("palette.open")} (${cap})`}
|
||||||
title={`${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}
|
{cap}
|
||||||
</Pressable>
|
</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
|
// 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.
|
// 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", () => {
|
describe("PlayerModal — fits the window's height", () => {
|
||||||
@@ -485,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
}) as unknown as MediaQueryList,
|
}) 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(() => {
|
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 () => {
|
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();
|
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,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -56,7 +57,7 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
@@ -106,6 +107,35 @@ const WIDE_PLAYER_PX = 1280;
|
|||||||
const SIDE_PANEL = "20rem";
|
const SIDE_PANEL = "20rem";
|
||||||
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
||||||
const MIN_VIDEO = "36rem";
|
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
|
// 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.
|
// 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>
|
</span>
|
||||||
);
|
);
|
||||||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
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({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
@@ -689,39 +722,48 @@ export default function PlayerModal({
|
|||||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||||
<div
|
<div
|
||||||
className={
|
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
|
<div
|
||||||
ref={mountRef}
|
ref={mountRef}
|
||||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
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>
|
</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
|
{/* 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
|
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
|
<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
|
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
|
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;
|
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||||
@@ -771,13 +813,18 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px bg-white/20" />
|
||||||
<IconButton
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||||
variant="overlay"
|
the window-filling view is the biggest, and YouTube's own button opens the
|
||||||
onClick={toggleTrueFullscreen}
|
system player. */}
|
||||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
{canFullscreenElements() && (
|
||||||
>
|
<IconButton
|
||||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
variant="overlay"
|
||||||
</IconButton>
|
onClick={toggleTrueFullscreen}
|
||||||
|
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -922,10 +969,13 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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),
|
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. */}
|
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" : ""}`}>
|
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 && (
|
{!navigated && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={active.channel_thumbnail}
|
src={active.channel_thumbnail}
|
||||||
@@ -973,7 +1023,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div
|
<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 && (
|
{active.view_count != null && (
|
||||||
<span>
|
<span>
|
||||||
@@ -1004,7 +1054,7 @@ export default function PlayerModal({
|
|||||||
) : (
|
) : (
|
||||||
// Linked video's stats come from the (already-fetched) video detail.
|
// Linked video's stats come from the (already-fetched) video detail.
|
||||||
<div
|
<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 && (
|
{detail.data?.view_count != null && (
|
||||||
<span>
|
<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
|
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||||
the docked bar offers OUR true fullscreen. */}
|
the docked bar offers OUR true fullscreen. */}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<IconButton
|
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||||
variant="outline"
|
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||||
size="md"
|
<IconButton
|
||||||
onClick={toggleFullscreen}
|
variant="outline"
|
||||||
label={t("player.fullscreen")}
|
size="md"
|
||||||
title={`${t("player.fullscreen")} · F`}
|
onClick={toggleFullscreen}
|
||||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
label={t("player.fullscreen")}
|
||||||
className={wide ? undefined : "ml-auto"}
|
title={`${t("player.fullscreen")} · F`}
|
||||||
>
|
>
|
||||||
<Maximize />
|
<Maximize />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
<DownloadButton
|
||||||
{!navigated && (
|
videoId={active.id}
|
||||||
<DownloadButton
|
title={active.title}
|
||||||
videoId={active.id}
|
variant="outline"
|
||||||
title={active.title}
|
size="md"
|
||||||
variant="outline"
|
/>
|
||||||
size="md"
|
{onRemoveFromList && (
|
||||||
/>
|
<IconButton
|
||||||
)}
|
variant="outline-danger"
|
||||||
{!navigated && onRemoveFromList && (
|
size="md"
|
||||||
<IconButton
|
onClick={() => void removeFromList()}
|
||||||
variant="outline-danger"
|
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||||
size="md"
|
// Download / Mark watched reads as "delete this video".
|
||||||
onClick={() => void removeFromList()}
|
label={t("playlists.removeItemOf", { title: active.title })}
|
||||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
title={t("playlists.removeItem")}
|
||||||
// Download / Mark watched reads as "delete this video".
|
>
|
||||||
label={t("playlists.removeItemOf", { title: active.title })}
|
<Trash2 />
|
||||||
title={t("playlists.removeItem")}
|
</IconButton>
|
||||||
>
|
)}
|
||||||
<Trash2 />
|
<Button
|
||||||
</IconButton>
|
variant="outline"
|
||||||
)}
|
size="sm"
|
||||||
{!navigated && (
|
pressed={watched}
|
||||||
<Button
|
onClick={() => setWatched(!watched)}
|
||||||
variant="outline"
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
size="sm"
|
icon={watched ? <CheckCheck /> : <Check />}
|
||||||
pressed={watched}
|
>
|
||||||
onClick={() => setWatched(!watched)}
|
{watched ? t("player.watched") : t("player.markWatched")}
|
||||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
</Button>
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
</div>
|
||||||
>
|
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
{wide && (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
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));
|
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 () => {
|
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
|
// 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
|
// 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 { FloatingTree } from "@floating-ui/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
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 = {
|
const handlers = {
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
onInfo: vi.fn(),
|
onInfo: vi.fn(),
|
||||||
onToggleWatched: vi.fn(),
|
onToggleWatched: vi.fn(),
|
||||||
onRestart: vi.fn(),
|
onRestart: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Grid c={c} handlers={handlers} {...opts} />
|
<Grid c={c} handlers={handlers} {...opts} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { handlers };
|
return { handlers, qc };
|
||||||
}
|
}
|
||||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||||
const rightClick = () =>
|
const rightClick = () =>
|
||||||
@@ -223,3 +230,189 @@ describe("a Plex title's menu", () => {
|
|||||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
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
|
<ContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
ariaLabel={t("plex.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}
|
{actions.panel}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -857,3 +857,73 @@ describe("the seek bar (C3)", () => {
|
|||||||
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
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";
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
beforeAll(() => {
|
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 {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
DEFAULT_PREFS,
|
DEFAULT_PREFS,
|
||||||
fmt,
|
fmt,
|
||||||
subOrdForLang,
|
subOrdForLang,
|
||||||
subShadowCss,
|
|
||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||||
@@ -70,7 +69,7 @@ import {
|
|||||||
usePlexTracks,
|
usePlexTracks,
|
||||||
useWallClock,
|
useWallClock,
|
||||||
} from "../lib/plexPlayerHooks";
|
} from "../lib/plexPlayerHooks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import {
|
import {
|
||||||
dispatch,
|
dispatch,
|
||||||
isActivationTarget,
|
isActivationTarget,
|
||||||
@@ -84,7 +83,8 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
|||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import { trapTab } from "./ui/dialogFocus";
|
import PlexSubtitles from "./PlexSubtitles";
|
||||||
|
import { lockBodyScroll, trapTab } from "./ui/dialogFocus";
|
||||||
import {
|
import {
|
||||||
pushLayer,
|
pushLayer,
|
||||||
removeLayer,
|
removeLayer,
|
||||||
@@ -150,10 +150,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const wrapRef = useRef<HTMLDivElement>(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 —
|
// 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
|
// hands it to its neighbour instead of <body>. The player is portaled outside the
|
||||||
// page scroller, whose keeper does not watch it.
|
// page scroller, whose keeper does not watch it.
|
||||||
useFocusKeeper(wrapRef);
|
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).
|
// The key of the last press that landed on faded bands, while it is held (see onKey).
|
||||||
const blindKeyRef = useRef<string | null>(null);
|
const blindKeyRef = useRef<string | null>(null);
|
||||||
const durationRef = useLatest(duration);
|
const durationRef = useLatest(duration);
|
||||||
@@ -403,7 +420,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
detail,
|
detail,
|
||||||
sessionK,
|
sessionK,
|
||||||
subOffset: prefs.subOffset,
|
subOffset: prefs.subOffset,
|
||||||
subPos: prefs.subPos,
|
|
||||||
restartSession,
|
restartSession,
|
||||||
setAudioTrack: (track: number) => engine.setAudioTrack(track),
|
setAudioTrack: (track: number) => engine.setAudioTrack(track),
|
||||||
patchPrefs,
|
patchPrefs,
|
||||||
@@ -1089,36 +1105,32 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
onClick={wake}
|
onClick={wake}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
|
// 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
|
// out at 80%×80% 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:
|
// 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
|
// 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`
|
// 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.
|
// 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
|
// 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
|
// 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
|
// `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.
|
// clientX/rect math stay correct under the identity transform.
|
||||||
style={{
|
style={{
|
||||||
cursor: uiVisible ? "default" : "none",
|
cursor: uiVisible ? "default" : "none",
|
||||||
...(fs
|
...(fs
|
||||||
? { width: "100vw", height: "100dvh" }
|
? { width: "100%", height: "100%" }
|
||||||
: {
|
: {
|
||||||
width: "80vw",
|
width: "80%",
|
||||||
height: "80dvh",
|
height: "80%",
|
||||||
transform: "scale(1.25)",
|
transform: "scale(1.25)",
|
||||||
transformOrigin: "top left",
|
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
|
<video
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
videoRef.current = el;
|
videoRef.current = el;
|
||||||
@@ -1134,8 +1146,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
onClick={onPictureClick}
|
onClick={onPictureClick}
|
||||||
playsInline
|
playsInline
|
||||||
>
|
>
|
||||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
{/* Text subtitles as a native track (same-origin → sent with the session cookie), kept
|
||||||
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
`hidden`: the browser fetches and parses the VTT, PlexSubtitles below draws the cues. */}
|
||||||
{selectedSub && subShowable && (
|
{selectedSub && subShowable && (
|
||||||
<track
|
<track
|
||||||
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
|
// 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>
|
</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
|
{/* 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
|
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 */}
|
{/* Bottom controls */}
|
||||||
<div
|
<div
|
||||||
|
ref={bottomBarRef}
|
||||||
{...holdWhileHovered}
|
{...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 ${
|
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
|
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">
|
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||||||
{fmt(abs)} / {fmt(duration)}
|
{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 —
|
{/* 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. */}
|
pushes later while paused, since the remaining time then rides the advancing clock. */}
|
||||||
{duration > 0 && (
|
{duration > 0 && (
|
||||||
@@ -1616,9 +1639,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
|
||||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
did nothing there, and the windowed player already fills the window. */}
|
||||||
</Ctrl>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
|||||||
|
|
||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import { api, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
|
|||||||
thumb: "",
|
thumb: "",
|
||||||
}) as unknown as PlexCard;
|
}) 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, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||||
const onPlay = vi.fn();
|
const onPlay = vi.fn();
|
||||||
const onBack = vi.fn();
|
const onBack = vi.fn();
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) client.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider
|
<QueryClientProvider client={client}>
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
||||||
>
|
|
||||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PlexPlaylistView · a row's menu", () => {
|
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 () => {
|
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)]);
|
mount([movie("1"), ep("e1", 1, 1)]);
|
||||||
await screen.findByText("Movie 1");
|
await screen.findByText("Movie 1");
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
|
|||||||
? []
|
? []
|
||||||
: menuTarget.kind === "item"
|
: menuTarget.kind === "item"
|
||||||
? [
|
? [
|
||||||
...titleActions.items(menuTarget.item),
|
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
|
||||||
...orderRows(menuTarget),
|
...orderRows(menuTarget),
|
||||||
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
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
|
// 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
|
// (@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.
|
// 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("SearchBar responsive wrapper", () => {
|
describe("SearchBar responsive wrapper", () => {
|
||||||
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
||||||
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||||
const cls = (container.firstElementChild as HTMLElement).className;
|
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:
|
// 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):
|
// 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:basis-full");
|
||||||
expect(cls).toContain("@max-[640px]/appmain:order-last");
|
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
|
// 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
|
// 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
|
// 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 (
|
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">
|
<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. */}
|
{/* `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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
fieldClass({ size: "free", pill: true, floating: true }),
|
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" />}
|
{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")}
|
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||||
>
|
>
|
||||||
{goIcon}
|
{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")}
|
{goLabel ?? t("header.go")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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 |
|
| 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` |
|
| 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) |
|
| 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` |
|
| 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());
|
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
|
// 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.
|
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
|
||||||
hoverDelay: { open: openDelay, close: 0 },
|
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;
|
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,
|
* 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. */
|
* and a tap is the card's play. */
|
||||||
pointerOnly?: boolean;
|
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
|
/** 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
|
* 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. */
|
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||||
@@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
|||||||
move: false,
|
move: false,
|
||||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
// 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).
|
// 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
|
// 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.
|
// menu, so the "did they mean it" question is mostly answered.
|
||||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
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.
|
// second copy of the lock is gone.
|
||||||
let scrollLocks = 0;
|
let scrollLocks = 0;
|
||||||
let unlockedOverflow = "";
|
let unlockedOverflow = "";
|
||||||
function lockBodyScroll(): () => void {
|
export function lockBodyScroll(): () => void {
|
||||||
if (scrollLocks++ === 0) {
|
if (scrollLocks++ === 0) {
|
||||||
unlockedOverflow = document.body.style.overflow;
|
unlockedOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
|
||||||
import { pinPlex, useComfortPin } from "./comfortPin";
|
import { pinPlex, useComfortPin } from "./comfortPin";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "./copyItems";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
@@ -46,15 +49,27 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
||||||
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
|
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
|
// 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.
|
// only a leaf is added from a card.
|
||||||
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
|
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`);
|
/** `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 = (
|
const items = (
|
||||||
c: PlexCard,
|
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[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
||||||
const playable = isPlexPlayable(c);
|
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
|
...(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 };
|
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",
|
"version": "0.102.0",
|
||||||
"date": "2026-10-08",
|
"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",
|
"version": "0.102.0",
|
||||||
"date": "2026-10-08",
|
"date": "2026-10-08",
|
||||||
|
|||||||
@@ -384,5 +384,11 @@
|
|||||||
"byGenre": "Browse by genre",
|
"byGenre": "Browse by genre",
|
||||||
"becauseWatched": "Because you watched {{title}}",
|
"becauseWatched": "Because you watched {{title}}",
|
||||||
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
|
"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",
|
"byGenre": "Böngészés műfaj szerint",
|
||||||
"becauseWatched": "Mert megnézted: {{title}}",
|
"becauseWatched": "Mert megnézted: {{title}}",
|
||||||
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
|
"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();
|
const qs = u.toString();
|
||||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
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
|
// 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.
|
// 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> => {
|
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
// The browser's Fullscreen API, WebKit included.
|
// 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
|
// `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
|
// 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
|
// 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;
|
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
|
/** 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. */
|
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
|
||||||
export function requestFullscreen(el: HTMLElement): void {
|
export function requestFullscreen(el: HTMLElement): void {
|
||||||
const e = el as WebkitElement;
|
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?.();
|
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
|
// 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),
|
// 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.
|
// 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.MANIFEST_PARSED, () => handlers.onManifestParsed());
|
||||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
|
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
|
// 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(),
|
detail: detail(),
|
||||||
sessionK,
|
sessionK,
|
||||||
subOffset: 0,
|
subOffset: 0,
|
||||||
subPos: 88,
|
|
||||||
restartSession: () => {},
|
restartSession: () => {},
|
||||||
setAudioTrack: () => false,
|
setAudioTrack: () => false,
|
||||||
patchPrefs: () => {},
|
patchPrefs: () => {},
|
||||||
@@ -316,7 +315,6 @@ function TrackHarness() {
|
|||||||
detail: detail(),
|
detail: detail(),
|
||||||
sessionK: 0,
|
sessionK: 0,
|
||||||
subOffset: 0,
|
subOffset: 0,
|
||||||
subPos: 88,
|
|
||||||
restartSession: () => {},
|
restartSession: () => {},
|
||||||
setAudioTrack: () => false,
|
setAudioTrack: () => false,
|
||||||
patchPrefs: () => {},
|
patchPrefs: () => {},
|
||||||
@@ -417,7 +415,6 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
|
|||||||
detail: detail(),
|
detail: detail(),
|
||||||
sessionK: 0,
|
sessionK: 0,
|
||||||
subOffset: 0,
|
subOffset: 0,
|
||||||
subPos: 88,
|
|
||||||
restartSession: (at) => restart(id, at),
|
restartSession: (at) => restart(id, at),
|
||||||
setAudioTrack: () => false,
|
setAudioTrack: () => false,
|
||||||
patchPrefs: () => {},
|
patchPrefs: () => {},
|
||||||
@@ -439,3 +436,98 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
|
|||||||
expect(restart).not.toHaveBeenCalled();
|
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 { useLatest } from "./useLatest";
|
||||||
import i18n, { plexLang } from "../i18n";
|
import i18n, { plexLang } from "../i18n";
|
||||||
import { markerSkip } from "./plexMarkers";
|
import { markerSkip } from "./plexMarkers";
|
||||||
|
import { ownTextTracks } from "./plexSubTracks";
|
||||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||||
|
|
||||||
@@ -585,7 +586,6 @@ export function usePlexTracks(opts: {
|
|||||||
detail: PlexItemDetail | undefined;
|
detail: PlexItemDetail | undefined;
|
||||||
sessionK: number;
|
sessionK: number;
|
||||||
subOffset: number;
|
subOffset: number;
|
||||||
subPos: number;
|
|
||||||
/** Restart the item's session at `startAt` (HlsEngine.start). */
|
/** Restart the item's session at `startAt` (HlsEngine.start). */
|
||||||
restartSession: (startAt: number) => void;
|
restartSession: (startAt: number) => void;
|
||||||
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
|
/** 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. */
|
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
|
||||||
subTrackListeners: { onErrorCapture: () => void };
|
subTrackListeners: { onErrorCapture: () => void };
|
||||||
} {
|
} {
|
||||||
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
|
const { id, detail, sessionK, subOffset, restartSession, setAudioTrack, patchPrefs } = opts;
|
||||||
opts;
|
|
||||||
const { videoRef, audioRef, aoffRef, playhead } = opts;
|
const { videoRef, audioRef, aoffRef, playhead } = opts;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
// 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
|
// 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.
|
// must be shifted by; the user offset nudges on top.
|
||||||
const subTrackOffset = sessionK - subOffApplied;
|
const subTrackOffset = sessionK - subOffApplied;
|
||||||
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
|
// Keep our own track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
|
||||||
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
|
// 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(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
|
const tracks = video.textTracks;
|
||||||
}, [subOrd, id, subTrackOffset, videoRef]);
|
const settle = () => {
|
||||||
|
const own = ownTextTracks(video);
|
||||||
// Position subtitle cues per the user's vertical setting (subPos). The video fills the viewport
|
for (const tr of Array.from(tracks)) {
|
||||||
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
|
const subtitle = tr.kind === "subtitles" || tr.kind === "captions";
|
||||||
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
|
const want = own.has(tr) ? "hidden" : subtitle ? "disabled" : tr.mode;
|
||||||
useEffect(() => {
|
if (tr.mode !== want) tr.mode = want;
|
||||||
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 bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
|
settle();
|
||||||
for (const tr of Array.from(video.textTracks)) bind(tr);
|
tracks.addEventListener("addtrack", settle);
|
||||||
const onAddTrack = (e: TrackEvent) => {
|
tracks.addEventListener("change", settle);
|
||||||
if (e.track) {
|
|
||||||
bind(e.track as TextTrack);
|
|
||||||
placeAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
|
||||||
placeAll();
|
|
||||||
return () => {
|
return () => {
|
||||||
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
tracks.removeEventListener("addtrack", settle);
|
||||||
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
tracks.removeEventListener("change", settle);
|
||||||
};
|
};
|
||||||
}, [id, subPos, videoRef]);
|
}, [subOrd, id, videoRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioOrd,
|
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
|
// 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
|
// 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
|
// 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).
|
// snow-white frame. A `text-shadow` value; undefined when the slider is 0 (shadow off).
|
||||||
export function subShadowCss(px: number, color: string): string {
|
export function subShadow(px: number, color: string): string | undefined {
|
||||||
if (px <= 0) return "";
|
if (px <= 0) return undefined;
|
||||||
const o = px;
|
const o = px;
|
||||||
const b = Math.max(1, Math.round(px / 2));
|
const b = Math.max(1, Math.round(px / 2));
|
||||||
const dirs: [number, number][] = [
|
const dirs: [number, number][] = [
|
||||||
@@ -94,7 +94,7 @@ export function subShadowCss(px: number, color: string): string {
|
|||||||
[-o, -o],
|
[-o, -o],
|
||||||
];
|
];
|
||||||
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
|
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
|
// 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;
|
style: CSSProperties;
|
||||||
} {
|
} {
|
||||||
const { ref, start, end } = useScrollEdges("y");
|
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
|
end ? fadePx : 0
|
||||||
}px), transparent)`;
|
}px), transparent)`;
|
||||||
return { ref, style: { maskImage: mask, WebkitMaskImage: mask } };
|
return { ref, style: { maskImage: mask, WebkitMaskImage: mask } };
|
||||||
|
|||||||
Reference in New Issue
Block a user