Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c003a48244 | ||
|
|
8a2334e198 | ||
|
|
1f010328df | ||
|
|
77638f7a0b | ||
|
|
2cff4ad954 | ||
|
|
8776586ca3 | ||
|
|
92a17aba83 | ||
|
|
fc53bc03bc | ||
|
|
6df257d7df | ||
|
|
5572499b56 | ||
|
|
76738f8ea9 | ||
|
|
568bf93510 | ||
|
|
942154cf49 | ||
|
|
3eb87e7108 | ||
|
|
851b97b09d | ||
|
|
ee94d1949a | ||
|
|
4f076b5d45 | ||
|
|
ece74a42ab | ||
|
|
600d5c39da | ||
|
|
4c7f73af9d | ||
|
|
5f42ff61c8 | ||
|
|
6dd82ef5e2 | ||
|
|
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,29 @@ 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.104.0 — 2026-10-10
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand.
|
||||||
|
- Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -1134,22 +1134,6 @@
|
|||||||
"endColumn": 48,
|
"endColumn": 48,
|
||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 39,
|
|
||||||
"endColumn": 45,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/plex/playback.py": [
|
"./app/routes/plex/playback.py": [
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Plex summaries and taglines as plain text
|
||||||
|
|
||||||
|
Revision ID: 0101_plex_text_plain
|
||||||
|
Revises: 0100_drop_plex_item_markers
|
||||||
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
|
Some Plex agents write a summary as HTML (`<p>…</p>`, `á`), and the app showed the tags as
|
||||||
|
text. The sync now stores every summary and tagline through `plain_text` (app/plex/plaintext.py);
|
||||||
|
this cleans the rows it wrote before, through the same function, so nothing waits for a full resync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
|
revision: str = "0101_plex_text_plain"
|
||||||
|
down_revision: str | None = "0100_drop_plex_item_markers"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
_COLUMNS = {
|
||||||
|
"plex_items": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||||
|
"plex_shows": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||||
|
"plex_collections": ("summary",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table, columns in _COLUMNS.items():
|
||||||
|
for col in columns:
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(f"SELECT id, {col} FROM {table} WHERE {col} ~ '[<&]'")
|
||||||
|
).fetchall()
|
||||||
|
updates = [
|
||||||
|
{"id": rid, "v": clean}
|
||||||
|
for rid, value in rows
|
||||||
|
if (clean := plain_text(value)) != value
|
||||||
|
]
|
||||||
|
if updates:
|
||||||
|
conn.execute(sa.text(f"UPDATE {table} SET {col} = :v WHERE id = :id"), updates)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass # the markup is not wanted back; the next sync would clean it again
|
||||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.discover import cards, queries
|
from app.discover import cards, queries
|
||||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import leaf_card, show_card
|
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||||
@@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
|
|||||||
sh = db.get(PlexShow, pin.plex_show_id)
|
sh = db.get(PlexShow, pin.plex_show_id)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
return None
|
return None
|
||||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||||
nxt = cards.from_plex(ep) if ep else None
|
nxt = cards.from_plex(ep) if ep else None
|
||||||
|
|
||||||
|
|||||||
@@ -256,16 +256,20 @@ def plex_in_progress(
|
|||||||
if show_libs:
|
if show_libs:
|
||||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
sq = apply_show_state_filter(
|
sq = apply_show_state_filter(
|
||||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
prog.c.total,
|
prog.c.total,
|
||||||
prog.c.watched,
|
prog.c.watched,
|
||||||
prog.c.inprog,
|
prog.c.inprog,
|
||||||
"in_progress",
|
"in_progress",
|
||||||
).where(PlexShow.library_id.in_(show_libs))
|
).where(PlexShow.library_id.in_(show_libs))
|
||||||
for sh, resumed_at in db.execute(
|
for sh, resumed_at, total, watched, inprog in db.execute(
|
||||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||||
).all():
|
).all():
|
||||||
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
out.append(
|
||||||
|
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,24 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the
|
|||||||
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||||
router. Pure functions — no DB, no request state."""
|
router. Pure functions — no DB, no request state."""
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from app.models import PlexItem, PlexShow, PlexState
|
from app.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
class ShowState(NamedTuple):
|
||||||
|
"""A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched
|
||||||
|
episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A
|
||||||
|
show part watched with its next episode untouched has none: Continue and Restart would both play
|
||||||
|
that episode from its start, so the card offers Play alone."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
resumable: bool
|
||||||
|
|
||||||
|
|
||||||
|
SHOW_NEW = ShowState("new", False)
|
||||||
|
|
||||||
|
|
||||||
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||||
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||||
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||||
@@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"type": "show",
|
"type": "show",
|
||||||
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
|||||||
"art": _art_url(sh.rating_key, sh.art_key),
|
"art": _art_url(sh.rating_key, sh.art_key),
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
"status": status,
|
"status": state.status,
|
||||||
|
"resumable": state.resumable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ the two never diverge — the same one-parser rule that keeps `people.py`'s cast
|
|||||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||||
|
|
||||||
from app.plex import people
|
from app.plex import people
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
|
|
||||||
def rich_detail(meta: dict) -> dict:
|
def rich_detail(meta: dict) -> dict:
|
||||||
@@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict:
|
|||||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||||
"studio": meta.get("studio"),
|
"studio": meta.get("studio"),
|
||||||
"tagline": meta.get("tagline"),
|
"tagline": plain_text(meta.get("tagline")),
|
||||||
"cast": people.cast_from_meta(meta),
|
"cast": people.cast_from_meta(meta),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
|
|||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex.client import PlexClient
|
from app.plex.client import PlexClient
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -109,8 +110,8 @@ def _overlay_pass(
|
|||||||
for g, meta in hu_by_guid.items():
|
for g, meta in hu_by_guid.items():
|
||||||
targets = by_guid[g]
|
targets = by_guid[g]
|
||||||
title_hu = (meta.get("title") or "").strip() or None
|
title_hu = (meta.get("title") or "").strip() or None
|
||||||
summary_hu = meta.get("summary")
|
summary_hu = plain_text(meta.get("summary"))
|
||||||
tagline_hu = (meta.get("tagline") or "").strip() or None
|
tagline_hu = plain_text(meta.get("tagline"))
|
||||||
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
||||||
for row in targets:
|
for row in targets:
|
||||||
row.title_hu = title_hu
|
row.title_hu = title_hu
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Plex text fields as plain text.
|
||||||
|
|
||||||
|
Plex hands a summary over as the agent wrote it, and some agents (a Hungarian TV listing, for one)
|
||||||
|
write HTML: `<p>…</p>` paragraphs and `á`-style entities. The app renders summaries as text,
|
||||||
|
so the tags showed literally on the info page and the hover card, and a card's 300-character cut
|
||||||
|
could land inside one. Every summary and tagline is stored through `plain_text`, and the migration
|
||||||
|
that cleaned the existing rows calls it too, so the two cannot disagree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from html.entities import html5
|
||||||
|
|
||||||
|
_BREAK = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||||
|
# A paragraph or block boundary: a blank line between the two. Only a tag name ends the match, so a
|
||||||
|
# bare "<" in prose ("a < b", "<3") is left alone.
|
||||||
|
_BLOCK = re.compile(r"</?(?:p|div|li|ul|ol|h[1-6]|blockquote)\b[^>]*>", re.IGNORECASE)
|
||||||
|
_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
|
||||||
|
_BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*")
|
||||||
|
|
||||||
|
# A well-formed character reference only. `html.unescape` alone also decodes the legacy forms that
|
||||||
|
# have no semicolon, so prose like "AT¬ice" or "R©" came out as "AT¬ice" and "R©". A named
|
||||||
|
# one counts only when its whole name is an entity: for "¬ice;" `html.unescape` still falls back
|
||||||
|
# to the legacy "¬" inside it.
|
||||||
|
_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(m: re.Match[str]) -> str:
|
||||||
|
ref = m.group()
|
||||||
|
if ref[1] != "#":
|
||||||
|
return html.unescape(ref) if ref[1:] in html5 else ref
|
||||||
|
if ref[2] in "xX":
|
||||||
|
return html.unescape(ref)
|
||||||
|
# `int()` refuses a decimal run past 4300 digits, and any number past seven significant digits is
|
||||||
|
# beyond U+10FFFF, which the parser turns into U+FFFD anyway.
|
||||||
|
digits = ref[2:-1].lstrip("0") or "0"
|
||||||
|
return "�" if len(digits) > 7 else html.unescape(f"&#{digits};")
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text(value: str | None) -> str | None:
|
||||||
|
"""`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line,
|
||||||
|
every other tag dropped and entities decoded. None or an all-blank result stays None."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if "<" in value or "&" in value:
|
||||||
|
value = _BREAK.sub("\n", value)
|
||||||
|
value = _BLOCK.sub("\n\n", value)
|
||||||
|
value = _TAG.sub("", value)
|
||||||
|
value = _REF.sub(_decode, value)
|
||||||
|
value = _BLANKS.sub("\n\n", value)
|
||||||
|
return value.strip() or None
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
|
|||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import PlexItem, PlexState
|
from app.models import PlexItem, PlexState
|
||||||
|
from app.plex.cards import ShowState
|
||||||
|
|
||||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||||
# same at every rail that merges two type-homogeneous lists in Python.
|
# same at every rail that merges two type-homogeneous lists in Python.
|
||||||
@@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str:
|
|||||||
return "new"
|
return "new"
|
||||||
|
|
||||||
|
|
||||||
|
def show_state(total: int, watched: int, inprog: int) -> ShowState:
|
||||||
|
"""`show_status` plus whether an episode holds a resume point (the card's `ShowState`)."""
|
||||||
|
return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0)
|
||||||
|
|
||||||
|
|
||||||
def rollup(cards: list[dict]) -> dict:
|
def rollup(cards: list[dict]) -> dict:
|
||||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||||
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||||
@@ -92,16 +98,16 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]:
|
||||||
if not show_ids:
|
if not show_ids:
|
||||||
return {}
|
return {}
|
||||||
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
||||||
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||||
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
||||||
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
|
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
|
||||||
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||||
if not item_ids:
|
if not item_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
|
|||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
||||||
@@ -468,7 +469,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||||||
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
||||||
row.guid = _canonical_guid(meta) or row.guid
|
row.guid = _canonical_guid(meta) or row.guid
|
||||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
row.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
row.summary = meta.get("summary")
|
row.summary = plain_text(meta.get("summary"))
|
||||||
row.year = meta.get("year")
|
row.year = meta.get("year")
|
||||||
row.duration_s = _dur_s(meta)
|
row.duration_s = _dur_s(meta)
|
||||||
row.thumb_key = meta.get("thumb")
|
row.thumb_key = meta.get("thumb")
|
||||||
@@ -576,7 +577,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
_canonical_guid(meta) or sh.guid
|
_canonical_guid(meta) or sh.guid
|
||||||
) # EN<->HU pairing key; keep prior if listing has none
|
) # EN<->HU pairing key; keep prior if listing has none
|
||||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
sh.summary = meta.get("summary")
|
sh.summary = plain_text(meta.get("summary"))
|
||||||
sh.year = meta.get("year")
|
sh.year = meta.get("year")
|
||||||
sh.thumb_key = meta.get("thumb")
|
sh.thumb_key = meta.get("thumb")
|
||||||
sh.art_key = meta.get("art")
|
sh.art_key = meta.get("art")
|
||||||
@@ -659,7 +660,7 @@ def _upsert_collection(
|
|||||||
db.add(col)
|
db.add(col)
|
||||||
col.library_id = lib_id
|
col.library_id = lib_id
|
||||||
col.title = (meta.get("title") or "").strip() or "Untitled"
|
col.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
col.summary = meta.get("summary")
|
col.summary = plain_text(meta.get("summary"))
|
||||||
col.thumb_key = meta.get("thumb")
|
col.thumb_key = meta.get("thumb")
|
||||||
col.child_count = meta.get("childCount")
|
col.child_count = meta.get("childCount")
|
||||||
col.smart = str(meta.get("smart")) == "1"
|
col.smart = str(meta.get("smart")) == "1"
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ def unified_library(
|
|||||||
cast(null(), Integer).label("season_count"),
|
cast(null(), Integer).label("season_count"),
|
||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
m_status.label("status"),
|
||||||
|
literal(False).label("resumable"),
|
||||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||||
@@ -211,6 +212,8 @@ def unified_library(
|
|||||||
PlexShow.child_count.label("season_count"),
|
PlexShow.child_count.label("season_count"),
|
||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
s_status.label("status"),
|
||||||
|
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||||
|
(inp > 0).label("resumable"),
|
||||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||||
agg.c.resumed_at.label("last_watched"),
|
agg.c.resumed_at.label("last_watched"),
|
||||||
@@ -276,6 +279,7 @@ def unified_library(
|
|||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"season_count": r.season_count,
|
"season_count": r.season_count,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
|
"resumable": r.resumable,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex.cards import display_title, movie_card, norm_lang, show_card
|
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||||
@@ -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
|
||||||
@@ -284,7 +318,7 @@ def collection_detail(
|
|||||||
if shows
|
if shows
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
|
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
||||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||||
|
|
||||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||||
@@ -325,7 +359,7 @@ def collection_detail(
|
|||||||
items = [
|
items = [
|
||||||
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
||||||
if m.kind == "movie"
|
if m.kind == "movie"
|
||||||
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
|
else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang)
|
||||||
for m in members
|
for m in members
|
||||||
]
|
]
|
||||||
years = [m.year for m in members if m.year is not None]
|
years = [m.year for m in members if m.year is not None]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
|
|||||||
from app import logs
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.discover import queries
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
PlexItem,
|
PlexItem,
|
||||||
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
|
|||||||
from app.plex import rollup, subtitles
|
from app.plex import rollup, subtitles
|
||||||
from app.plex.query import title_col
|
from app.plex.query import title_col
|
||||||
from app.plex import tmdb as plex_tmdb
|
from app.plex import tmdb as plex_tmdb
|
||||||
from app.plex.cards import episode_card, movie_card, show_card
|
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||||
@@ -221,6 +222,24 @@ def show_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/show/{rating_key}/next")
|
||||||
|
def show_next(
|
||||||
|
rating_key: str,
|
||||||
|
lang: str = "en",
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The episode a show's card plays: the one in progress, else the first unwatched, else the first
|
||||||
|
(`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow).
|
||||||
|
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
|
||||||
|
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
|
||||||
|
`next` is null for a show with no episodes."""
|
||||||
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
|
if sh is None:
|
||||||
|
raise not_found("show")
|
||||||
|
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||||
|
|
||||||
|
|
||||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||||
season subpage, read from the same cached show-detail payload)."""
|
season subpage, read from the same cached show-detail payload)."""
|
||||||
@@ -260,7 +279,7 @@ def _relatedshow_cards(
|
|||||||
if sh is None or rk in seen:
|
if sh is None or rk in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(rk)
|
seen.add(rk)
|
||||||
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
|
out.append(show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang))
|
||||||
if len(out) >= 20:
|
if len(out) >= 20:
|
||||||
break
|
break
|
||||||
return out
|
return out
|
||||||
@@ -504,6 +523,7 @@ def item_detail(
|
|||||||
|
|
||||||
prev_id, next_id = _episode_nav(db, it)
|
prev_id, next_id = _episode_nav(db, it)
|
||||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||||
|
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
|
||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"kind": it.kind,
|
"kind": it.kind,
|
||||||
@@ -537,6 +557,9 @@ def item_detail(
|
|||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
|
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
|
||||||
|
# The episode page links up to its show and its season (their rating keys = the view keys).
|
||||||
|
"show_id": show.rating_key if show else None,
|
||||||
|
"season_id": season.rating_key if season else None,
|
||||||
"season_number": it.season_number,
|
"season_number": it.season_number,
|
||||||
"episode_number": it.episode_number,
|
"episode_number": it.episode_number,
|
||||||
"prev_id": prev_id,
|
"prev_id": prev_id,
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ from app.auth import current_user
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import PlexItem, PlexShow, PlexState, User
|
from app.models import PlexItem, PlexShow, PlexState, User
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
from app.plex.cards import (
|
||||||
|
SHOW_NEW,
|
||||||
|
display_title,
|
||||||
|
leaf_card,
|
||||||
|
movie_card,
|
||||||
|
show_card,
|
||||||
|
)
|
||||||
from app.plex.query import (
|
from app.plex.query import (
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
apply_meta_filters,
|
||||||
@@ -69,7 +75,7 @@ def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> lis
|
|||||||
|
|
||||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||||
return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows]
|
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||||
|
|
||||||
|
|
||||||
def _recently_added(
|
def _recently_added(
|
||||||
@@ -232,7 +238,9 @@ def _continue_watching(
|
|||||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
sq = apply_show_state_filter(
|
sq = apply_show_state_filter(
|
||||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
prog.c.total,
|
prog.c.total,
|
||||||
prog.c.watched,
|
prog.c.watched,
|
||||||
prog.c.inprog,
|
prog.c.inprog,
|
||||||
@@ -243,8 +251,9 @@ def _continue_watching(
|
|||||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).all()
|
).all()
|
||||||
for sh, resumed_at in srows:
|
for sh, resumed_at, total, watched, inprog in srows:
|
||||||
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
state = rollup.show_state(total, watched, inprog)
|
||||||
|
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||||
|
|
||||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||||
@@ -402,11 +411,71 @@ def discover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||||
|
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||||||
|
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||||||
|
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||||||
|
fresh = (
|
||||||
|
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if exclude
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if len(fresh) == per:
|
||||||
|
return list(fresh)
|
||||||
|
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||||||
|
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||||||
|
return [*fresh, *rest.all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _kept(
|
||||||
|
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||||
|
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||||
|
movies = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
PlexItem.rating_key.in_(keep),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if movie_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
shows = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if show_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
cards = {
|
||||||
|
c["id"]: c
|
||||||
|
for c in [
|
||||||
|
*_movie_cards(db, uid, list(movies), lang),
|
||||||
|
*_show_cards(db, uid, list(shows), lang),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [cards[k] for k in keep if k in cards]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/surprise")
|
@router.get("/surprise")
|
||||||
def surprise(
|
def surprise(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
lang: str = "en",
|
lang: str = "en",
|
||||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||||
|
# The previous draw's rating keys: a reshuffle deals other titles first.
|
||||||
|
exclude: list[str] = Query(default=[], max_length=40),
|
||||||
|
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
|
||||||
|
# toggle) while the draw itself stands until the ↻.
|
||||||
|
keep: list[str] = Query(default=[], max_length=40),
|
||||||
f: LibraryFilters = Depends(),
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -419,6 +488,8 @@ def surprise(
|
|||||||
lang = _norm_lang(lang)
|
lang = _norm_lang(lang)
|
||||||
p = f.as_dict()
|
p = f.as_dict()
|
||||||
movie_ids, show_ids = _scope_libs(db, scope)
|
movie_ids, show_ids = _scope_libs(db, scope)
|
||||||
|
if keep:
|
||||||
|
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||||
per = min(limit, SURPRISE_PER_KIND)
|
per = min(limit, SURPRISE_PER_KIND)
|
||||||
movies = []
|
movies = []
|
||||||
if movie_ids:
|
if movie_ids:
|
||||||
@@ -432,23 +503,21 @@ def surprise(
|
|||||||
)
|
)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
movies = (
|
movies = _draw(
|
||||||
db.execute(
|
db,
|
||||||
_movie_meta(
|
_movie_meta(
|
||||||
select(PlexItem).where(
|
select(PlexItem).where(
|
||||||
PlexItem.kind == "movie",
|
PlexItem.kind == "movie",
|
||||||
PlexItem.library_id.in_(movie_ids),
|
PlexItem.library_id.in_(movie_ids),
|
||||||
~watched_movie,
|
~watched_movie,
|
||||||
),
|
),
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
f,
|
f,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexItem.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
shows = []
|
shows = []
|
||||||
if show_ids:
|
if show_ids:
|
||||||
@@ -458,21 +527,19 @@ def surprise(
|
|||||||
not_fully_watched = or_(
|
not_fully_watched = or_(
|
||||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||||
)
|
)
|
||||||
shows = (
|
shows = _draw(
|
||||||
db.execute(
|
db,
|
||||||
apply_meta_filters(
|
apply_meta_filters(
|
||||||
select(PlexShow)
|
select(PlexShow)
|
||||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||||
PlexShow,
|
PlexShow,
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexShow.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
mcards = _movie_cards(db, user.id, movies, lang)
|
mcards = _movie_cards(db, user.id, movies, lang)
|
||||||
scards = _show_cards(db, user.id, shows, lang)
|
scards = _show_cards(db, user.id, shows, lang)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex import tmdb as plex_tmdb
|
from app.plex import tmdb as plex_tmdb
|
||||||
from app.plex.cards import movie_card, norm_lang, show_card
|
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
||||||
from app.plex.query import content_libs, split_csv, title_col
|
from app.plex.query import content_libs, split_csv, title_col
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
@@ -57,7 +57,7 @@ def _resolve_known_for(
|
|||||||
# A credit resolves against its OWN media_type's bucket only.
|
# A credit resolves against its OWN media_type's bucket only.
|
||||||
if k.get("media_type") == "tv":
|
if k.get("media_type") == "tv":
|
||||||
if g in shows:
|
if g in shows:
|
||||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
|
out.append(show_card(shows[g], show_statuses.get(shows[g].id, SHOW_NEW), lang=lang))
|
||||||
elif g in movies:
|
elif g in movies:
|
||||||
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
||||||
return out[:_KNOWN_FOR_SHOWN]
|
return out[:_KNOWN_FOR_SHOWN]
|
||||||
@@ -245,7 +245,7 @@ def person_detail(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||||
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
|
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||||
|
|
||||||
known_for = _resolve_known_for(
|
known_for = _resolve_known_for(
|
||||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,7 +19,7 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import detail
|
from app.plex import detail
|
||||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||||
|
|
||||||
@@ -276,8 +276,16 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
|||||||
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
||||||
db.add(show)
|
db.add(show)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||||
|
db.add(season)
|
||||||
|
db.flush()
|
||||||
ep = PlexItem(
|
ep = PlexItem(
|
||||||
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
|
rating_key="e1",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="Pilot",
|
||||||
|
show_id=show.id,
|
||||||
|
season_id=season.id,
|
||||||
)
|
)
|
||||||
db.add(ep)
|
db.add(ep)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -289,6 +297,8 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
||||||
|
# The page's links up to the show and the season.
|
||||||
|
assert (body["show_id"], body["season_id"]) == ("s1", "se1")
|
||||||
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
||||||
db.refresh(ep)
|
db.refresh(ep)
|
||||||
assert ep.full_cast is None # episodes are not persisted/enriched
|
assert ep.full_cast is None # episodes are not persisted/enriched
|
||||||
@@ -436,3 +446,63 @@ def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep
|
|||||||
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
||||||
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _show_with_episodes(db, n=3):
|
||||||
|
lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show")
|
||||||
|
db.add(show)
|
||||||
|
db.flush()
|
||||||
|
eps = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"sn1e{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title=f"Episode {i}",
|
||||||
|
show_id=show.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=i,
|
||||||
|
)
|
||||||
|
for i in range(1, n + 1)
|
||||||
|
]
|
||||||
|
db.add_all(eps)
|
||||||
|
db.flush()
|
||||||
|
return eps
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep):
|
||||||
|
"""A show card's picture plays: never started → its first episode."""
|
||||||
|
_show_with_episodes(db)
|
||||||
|
user = _user(db, "next1@example.com")
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
nxt = resp.json()["next"]
|
||||||
|
assert nxt["id"] == "sn1e1"
|
||||||
|
assert nxt["show_title"] == "Next Show"
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep):
|
||||||
|
"""Started → the episode in progress (the show page's Resume), not the first unwatched."""
|
||||||
|
eps = _show_with_episodes(db)
|
||||||
|
user = _user(db, "next2@example.com")
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched"))
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120))
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.json()["next"]["id"] == "sn1e3"
|
||||||
|
assert resp.json()["next"]["position_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
_show_with_episodes(db, n=0)
|
||||||
|
user = _user(db, "next3@example.com")
|
||||||
|
db.commit()
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == {
|
||||||
|
"next": None
|
||||||
|
}
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404
|
||||||
|
|||||||
@@ -165,6 +165,30 @@ def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
|
|||||||
assert "m1" not in ids
|
assert "m1" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_continue_watching_show_says_whether_it_has_an_episode_to_resume(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
# Both in-progress rails (Discover's and Home's) agree with the one rollup: a show part watched
|
||||||
|
# with nothing to resume offers Play alone, not Continue + Restart.
|
||||||
|
from app.discover.queries import plex_in_progress
|
||||||
|
|
||||||
|
user = _seed(db)
|
||||||
|
|
||||||
|
def rails():
|
||||||
|
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||||
|
home = {c["id"]: c for _, c in plex_in_progress(db, user.id, lang="en")}
|
||||||
|
return {c["id"]: c for c in d["continue_watching"]}["sh1"], home["sh1"]
|
||||||
|
|
||||||
|
assert [c["resumable"] for c in rails()] == [True, True]
|
||||||
|
ep1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e1")).scalar_one()
|
||||||
|
st = db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == ep1.id)
|
||||||
|
).scalar_one()
|
||||||
|
st.status, st.position_seconds = "watched", 0
|
||||||
|
db.commit()
|
||||||
|
assert [(c["status"], c["resumable"]) for c in rails()] == [("in_progress", False)] * 2
|
||||||
|
|
||||||
|
|
||||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
||||||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||||||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||||||
@@ -316,6 +340,35 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_d
|
|||||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep):
|
||||||
|
# The ↻ sends the previous draw: what is left of the pool comes first, and a pool smaller than a
|
||||||
|
# draw is topped up from the excluded titles rather than leaving the rail short.
|
||||||
|
user = _seed(db)
|
||||||
|
|
||||||
|
def movies(url: str) -> list[str]:
|
||||||
|
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||||
|
return [c["id"] for c in items if c["type"] == "movie"]
|
||||||
|
|
||||||
|
for _ in range(5): # random order: one lucky pass would prove nothing
|
||||||
|
got = movies("/api/plex/surprise?scope=movie&exclude=m2")
|
||||||
|
assert got[0] == "m3" and set(got) == {"m2", "m3"}
|
||||||
|
assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_kept_draw_is_read_again_not_redrawn(db, monkeypatch, override_dep):
|
||||||
|
# The rail's draw stands until the ↻, but its cards' watch state moves: `keep` answers the titles on
|
||||||
|
# screen, in their order and as they stand now, a watched one included; the scope still bounds it.
|
||||||
|
user = _seed(db)
|
||||||
|
url = "/api/plex/surprise?scope=both&keep=m3&keep=sh1&keep=m1&keep=nope"
|
||||||
|
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||||
|
assert _ids(items) == ["m3", "sh1", "m1"]
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["m1"]["status"] == "watched"
|
||||||
|
assert by_id["m3"]["position_seconds"] > 0
|
||||||
|
movie_only = "/api/plex/surprise?scope=movie&keep=sh1&keep=m2"
|
||||||
|
assert _ids(_get(db, user, monkeypatch, override_dep, movie_only).json()["items"]) == ["m2"]
|
||||||
|
|
||||||
|
|
||||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||||
user = _seed(db)
|
user = _seed(db)
|
||||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Plex summaries and taglines are stored as plain text.
|
||||||
|
|
||||||
|
A Hungarian TV agent hands summaries over as HTML (`<p>A műsorban … párosnak …</p>`), and
|
||||||
|
the info page and the hover card showed the tags. `plain_text` is the one cleaner: the sync's every
|
||||||
|
summary path and the HU overlay store through it, and migration 0101 cleaned the rows written before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
|
||||||
|
from app.plex.detail import rich_detail
|
||||||
|
from app.plex.overlay import sync_overlay
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
from app.plex import sync
|
||||||
|
from app.plex.sync import _apply_item, _upsert_collection
|
||||||
|
|
||||||
|
HU_SUMMARY = "<p>A műsorban minden párosnak stoppal kell eljutnia.</p>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw, clean",
|
||||||
|
[
|
||||||
|
(HU_SUMMARY, "A műsorban minden párosnak stoppal kell eljutnia."),
|
||||||
|
("<p>One.</p><p>Two.</p>", "One.\n\nTwo."),
|
||||||
|
("Line one<br>line two<BR/>three", "Line one\nline two\nthree"),
|
||||||
|
("<b>Bold</b> & <i>more</i>", "Bold & more"),
|
||||||
|
# Prose that only looks like markup stays as written.
|
||||||
|
("a < b and 1 <3 you", "a < b and 1 <3 you"),
|
||||||
|
# Only a reference with its semicolon is one: an ampersand in prose stays as written.
|
||||||
|
("AT¬ice, R© and H&M", "AT¬ice, R© and H&M"),
|
||||||
|
# ...and only a whole entity name: a legacy one at its start ("¬", "&") is not.
|
||||||
|
("AT¬ice; and &x;", "AT¬ice; and &x;"),
|
||||||
|
("été ©", "été ©"),
|
||||||
|
# A decimal reference too long for int() to parse is out of range, like �.
|
||||||
|
("a &#" + "9" * 5000 + "; b", "a � b"),
|
||||||
|
("&#" + "0" * 5000 + "65;", "A"),
|
||||||
|
("Plain summary.", "Plain summary."),
|
||||||
|
(" padded ", "padded"),
|
||||||
|
("<p></p>", None),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_plain_text(raw, clean):
|
||||||
|
assert plain_text(raw) == clean
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_item_sync_stores_a_summary_as_plain_text():
|
||||||
|
row = PlexItem()
|
||||||
|
_apply_item(row, lib_id=1, kind="episode", meta={"title": "Ep", "summary": HU_SUMMARY})
|
||||||
|
assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_show_sync_stores_a_summary_as_plain_text(db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.commit()
|
||||||
|
meta = {"ratingKey": "s1", "title": "Show", "summary": HU_SUMMARY}
|
||||||
|
|
||||||
|
def listing(_plex, _key, typ, progress=None):
|
||||||
|
if progress is not None:
|
||||||
|
progress["complete"] = True
|
||||||
|
return [meta] if typ == sync._TYPE_SHOW else []
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync, "_paginate", listing)
|
||||||
|
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
|
||||||
|
db.commit()
|
||||||
|
show = db.execute(select(PlexShow).where(PlexShow.rating_key == "s1")).scalar_one()
|
||||||
|
assert show.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_collection_stores_its_summary_as_plain_text(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
col = _upsert_collection(db, None, "c1", lib.id, {"title": "C", "summary": "<p>A & B</p>"})
|
||||||
|
assert col.summary == "A & B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fetched_tagline_is_plain_text():
|
||||||
|
assert rich_detail({"tagline": "<i>An offer</i> & more"})["tagline"] == "An offer & more"
|
||||||
|
|
||||||
|
|
||||||
|
class _StubPlex:
|
||||||
|
def __init__(self, items: list[dict]):
|
||||||
|
self._items = items
|
||||||
|
|
||||||
|
def section_items(self, key, item_type, start, size):
|
||||||
|
return self._items[start : start + size], len(self._items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hungarian_overlay_stores_plain_text(db):
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||||
|
db.add_all([en_lib, hu_lib])
|
||||||
|
db.flush()
|
||||||
|
hu_lib.overlay_of = en_lib.id
|
||||||
|
row = PlexItem(rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"ratingKey": "h1",
|
||||||
|
"title": "Szemtől szemben",
|
||||||
|
"summary": HU_SUMMARY,
|
||||||
|
"tagline": "<i>Egy élet</i>",
|
||||||
|
"Guid": [{"id": "tmdb://1"}],
|
||||||
|
}
|
||||||
|
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {"overlaid": 0})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
assert row.summary_hu == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
assert row.tagline_hu == "Egy élet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_0101_cleans_the_stored_rows(db):
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0100_drop_plex_item_markers")
|
||||||
|
upgraded = False
|
||||||
|
try:
|
||||||
|
with Session(engine) as s:
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
|
||||||
|
s.add(lib)
|
||||||
|
s.flush()
|
||||||
|
ep = PlexItem(
|
||||||
|
rating_key="e1",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="Ep",
|
||||||
|
summary=HU_SUMMARY,
|
||||||
|
tagline_hu="<i>x</i>",
|
||||||
|
)
|
||||||
|
plain = PlexItem(
|
||||||
|
rating_key="m1", library_id=lib.id, kind="movie", title="M", summary="a < b"
|
||||||
|
)
|
||||||
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="S", summary_hu="<p>Hu</p>")
|
||||||
|
col = PlexCollection(rating_key="c1", library_id=lib.id, title="C", summary="A & B")
|
||||||
|
s.add_all([ep, plain, show, col])
|
||||||
|
s.commit()
|
||||||
|
ids = (ep.id, plain.id, show.id, col.id)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
upgraded = True
|
||||||
|
with Session(engine) as s:
|
||||||
|
ep2, plain2 = s.get(PlexItem, ids[0]), s.get(PlexItem, ids[1])
|
||||||
|
show2, col2 = s.get(PlexShow, ids[2]), s.get(PlexCollection, ids[3])
|
||||||
|
assert ep2 and plain2 and show2 and col2
|
||||||
|
assert ep2.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
assert ep2.tagline_hu == "x"
|
||||||
|
assert plain2.summary == "a < b"
|
||||||
|
assert show2.summary_hu == "Hu"
|
||||||
|
assert col2.summary == "A & B"
|
||||||
|
finally:
|
||||||
|
if not upgraded:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
@@ -18,6 +18,7 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import rollup, sync
|
from app.plex import rollup, sync
|
||||||
|
from app.plex.cards import ShowState
|
||||||
|
|
||||||
|
|
||||||
def _dt(day: int) -> datetime:
|
def _dt(day: int) -> datetime:
|
||||||
@@ -184,21 +185,53 @@ class TestRollup:
|
|||||||
def test_state_map_new_inprogress_watched(self, db):
|
def test_state_map_new_inprogress_watched(self, db):
|
||||||
s = _seed(db)
|
s = _seed(db)
|
||||||
show, user = s["show"], s["user"]
|
show, user = s["show"], s["user"]
|
||||||
# e1 in-progress, e2 new -> in_progress
|
# e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable
|
||||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
|
||||||
# empty input short-circuits
|
# empty input short-circuits
|
||||||
assert rollup.state_map(db, user.id, []) == {}
|
assert rollup.state_map(db, user.id, []) == {}
|
||||||
# a fresh user with no state -> new
|
# a fresh user with no state -> new
|
||||||
other = User(email="rollup-other@example.com")
|
other = User(email="rollup-other@example.com")
|
||||||
db.add(other)
|
db.add(other)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
|
||||||
# mark every episode watched for `user` -> watched
|
# e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart
|
||||||
|
# would both play e2 from its start
|
||||||
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
||||||
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
e1, e2 = db.execute(
|
||||||
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number)
|
||||||
|
).scalars()
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
assert rollup.state_map(db, user.id, [show.id]) == {
|
||||||
|
show.id: ShowState("in_progress", False)
|
||||||
|
}
|
||||||
|
# mark every episode watched for `user` -> watched
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)}
|
||||||
|
|
||||||
|
def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume(
|
||||||
|
self, db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
s = _seed(db)
|
||||||
|
user = s["user"]
|
||||||
|
cards = {
|
||||||
|
i["id"]: i
|
||||||
|
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||||
|
}
|
||||||
|
assert cards["s1"]["resumable"] is True
|
||||||
|
# e1 finished, e2 untouched: in progress, but nothing to resume
|
||||||
|
e1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "e1")).scalar_one()
|
||||||
|
db.execute(
|
||||||
|
delete(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == e1.id)
|
||||||
|
)
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
cards = {
|
||||||
|
i["id"]: i
|
||||||
|
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||||
|
}
|
||||||
|
assert (cards["s1"]["status"], cards["s1"]["resumable"]) == ("in_progress", False)
|
||||||
|
|
||||||
|
|
||||||
def _fake_paginate(by_type):
|
def _fake_paginate(by_type):
|
||||||
|
|||||||
+129
-110
@@ -135,9 +135,11 @@ style guide.
|
|||||||
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
|
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
|
||||||
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
|
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
|
||||||
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
|
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
|
||||||
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
|
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The
|
||||||
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
exceptions are an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||||||
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
|
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14); and a dark
|
||||||
|
translucent button on a picture (the `media` variant: a card's hover row, a rail's scroll arrows, Restart),
|
||||||
|
whose hover is a 2px accent ring over a darker body, since a layer is lost in the picture.
|
||||||
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
||||||
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
||||||
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
||||||
@@ -398,7 +400,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
|||||||
`mediaTitleClass`, `mediaPictureBox`).
|
`mediaTitleClass`, `mediaPictureBox`).
|
||||||
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||||||
one round media Play (the button family's `media` variant), only on a card whose click starts
|
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||||||
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart, offered only
|
||||||
|
when an episode holds a resume point), else the first unwatched, else the first, which the click asks the server for (`/show/{id}/next`); its title
|
||||||
|
and an ⓘ open the show's page, and one with no episodes opens there. The hover row (watched, save or
|
||||||
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||||||
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||||||
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||||||
@@ -411,7 +415,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
|||||||
had become the strip's.
|
had become the strip's.
|
||||||
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||||||
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||||||
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
`useMediaPreview`); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the
|
||||||
|
panel opens. Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||||
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||||||
native `title` tooltip.
|
native `title` tooltip.
|
||||||
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||||||
@@ -531,9 +536,15 @@ of a full-width panel and nothing on the right.
|
|||||||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
|
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
|
||||||
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
|
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
|
||||||
"Play from the beginning", and its picture carries the cards' progress bar.
|
"Play from the beginning", and its picture carries the cards' progress bar.
|
||||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
|
||||||
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
|
||||||
live). [WCAG 1.4.13]
|
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
|
||||||
|
under the video (stacked). Wide, the panel shows the description outright, clamped to three
|
||||||
|
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
|
||||||
|
strip of close, expand and Previous / Next, kept shut as the queue steps on and open again on a new
|
||||||
|
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
|
||||||
|
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
|
||||||
|
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
|
||||||
|
|
||||||
## Dialogs — _Decided_ (C4, built in S3f)
|
## Dialogs — _Decided_ (C4, built in S3f)
|
||||||
|
|
||||||
@@ -609,105 +620,113 @@ of a full-width panel and nothing on the right.
|
|||||||
|
|
||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
| Date | Decision | Chosen | Over | Why |
|
| Date | Decision | Chosen | Over | Why |
|
||||||
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||||
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||||
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||||
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||||
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||||
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||||
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||||
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||||
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||||
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||||
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||||
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||||
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||||
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||||
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||||
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||||
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||||
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||||
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||||
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||||
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||||
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||||
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||||
| 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 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||||
|
| 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" |
|
||||||
|
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
||||||
|
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
||||||
|
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
|
||||||
|
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
|
||||||
|
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
|
||||||
|
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
|
||||||
|
// Unwatched view. The grid is a stub that shows what Feed hands it.
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||||
|
vi.mock("./VirtualFeed", () => ({
|
||||||
|
default: ({
|
||||||
|
items,
|
||||||
|
onState,
|
||||||
|
fetchNextPage,
|
||||||
|
}: {
|
||||||
|
items: { id: string; status: string }[];
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
fetchNextPage: (o: object) => unknown;
|
||||||
|
}) => (
|
||||||
|
<ol aria-label="results">
|
||||||
|
{items.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<span>{`${v.id}:${v.status}`}</span>
|
||||||
|
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
|
||||||
|
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<button onClick={() => void fetchNextPage({})}>more</button>
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Feed from "./Feed";
|
||||||
|
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILTERS: FeedFilters = {
|
||||||
|
tags: [],
|
||||||
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
|
channelIds: [],
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "all",
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = (id: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
channel_id: "UCx",
|
||||||
|
channel_title: "Channel",
|
||||||
|
status: "new",
|
||||||
|
position_seconds: 0,
|
||||||
|
duration_seconds: 600,
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||||
|
}) as unknown as Video;
|
||||||
|
|
||||||
|
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
|
||||||
|
const page = (ids: string[], next: string | null = null) =>
|
||||||
|
({ items: ids.map(video), next_cursor: next }) as never;
|
||||||
|
|
||||||
|
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
|
||||||
|
vi.spyOn(api, "feed").mockImplementation(feed as never);
|
||||||
|
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||||
|
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
|
||||||
|
else vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
|
const ui = (filters: FeedFilters) => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Feed
|
||||||
|
filters={filters}
|
||||||
|
setFilters={() => {}}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
canRead
|
||||||
|
ytSearch={null}
|
||||||
|
onYtSearch={() => {}}
|
||||||
|
onExitYtSearch={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const r = render(ui(FILTERS));
|
||||||
|
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
|
|
||||||
|
describe("Feed — optimistic overrides", () => {
|
||||||
|
it("give way to a refetch that lands after them", async () => {
|
||||||
|
let answer: (v: unknown) => void = () => {};
|
||||||
|
const feed = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(page(["v1"]))
|
||||||
|
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||||
|
const { qc } = mount(feed);
|
||||||
|
fireEvent.click(await screen.findByText("watch v1"));
|
||||||
|
await screen.findByText("v1:watched");
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||||
|
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||||
|
await settle();
|
||||||
|
await act(async () => answer(page(["v1"])));
|
||||||
|
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keep a mark made while the refetch was in flight", async () => {
|
||||||
|
let answer: (v: unknown) => void = () => {};
|
||||||
|
const feed = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(page(["v1"]))
|
||||||
|
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||||
|
const { qc } = mount(feed);
|
||||||
|
await screen.findByText("v1:new");
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||||
|
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getByText("watch v1"));
|
||||||
|
await screen.findByText("v1:watched");
|
||||||
|
// The answer the server gave before it heard of the mark.
|
||||||
|
await act(async () => answer(page(["v1"])));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keep a mark sent before the refetch set out but not answered by then", async () => {
|
||||||
|
// The server may read the list before it commits the mark: that answer is older than the mark.
|
||||||
|
let answer: (v: unknown) => void = () => {};
|
||||||
|
let commit: (v: unknown) => void = () => {};
|
||||||
|
const feed = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(page(["v1"]))
|
||||||
|
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||||
|
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
|
||||||
|
fireEvent.click(await screen.findByText("watch v1"));
|
||||||
|
await screen.findByText("v1:watched");
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||||
|
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||||
|
await settle();
|
||||||
|
await act(async () => answer(page(["v1"])));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||||
|
await act(async () => commit({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are taken back by a failed write only while no later write stands", async () => {
|
||||||
|
// Marked watched, then hidden before the first answer came back: the refused mark must not take
|
||||||
|
// the hide with it and put the card back in the view.
|
||||||
|
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
|
||||||
|
mount(
|
||||||
|
vi.fn().mockResolvedValue(page(["v1", "v2"])),
|
||||||
|
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByText("watch v1"));
|
||||||
|
fireEvent.click(await screen.findByText("hide v1"));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
|
||||||
|
await act(async () => writes[0]?.reject(new Error("refused")));
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByText(/^v1:/)).toBeNull();
|
||||||
|
await act(async () => writes[1]?.reject(new Error("refused")));
|
||||||
|
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survive an append of the next page", async () => {
|
||||||
|
let answer: (v: unknown) => void = () => {};
|
||||||
|
const feed = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(page(["v1"], "c1"))
|
||||||
|
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||||
|
mount(feed);
|
||||||
|
fireEvent.click(await screen.findByText("watch v1"));
|
||||||
|
await screen.findByText("v1:watched");
|
||||||
|
fireEvent.click(screen.getByText("more"));
|
||||||
|
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||||
|
await settle();
|
||||||
|
await act(async () => answer(page(["v2"])));
|
||||||
|
await screen.findByText("v2:new");
|
||||||
|
await settle();
|
||||||
|
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are dropped by a change of the filters", async () => {
|
||||||
|
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
|
||||||
|
fireEvent.click(await screen.findByText("watch v1"));
|
||||||
|
await screen.findByText("v1:watched");
|
||||||
|
rerender({ ...FILTERS });
|
||||||
|
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view
|
||||||
|
// at once, then came back as "new" when the player closed, until a reload (owner, on a channel page).
|
||||||
|
// The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the
|
||||||
|
// feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched.
|
||||||
|
// The grid and the player are stubs that show what Feed hands them.
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||||
|
// What the grid was handed for its card menu's channel-filter row.
|
||||||
|
const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {};
|
||||||
|
vi.mock("./VirtualFeed", () => ({
|
||||||
|
default: ({
|
||||||
|
items,
|
||||||
|
onOpen,
|
||||||
|
channelFilter,
|
||||||
|
}: {
|
||||||
|
items: { id: string }[];
|
||||||
|
onOpen: (v: unknown) => void;
|
||||||
|
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
|
||||||
|
}) => (
|
||||||
|
(grid.channelFilter = channelFilter),
|
||||||
|
(
|
||||||
|
<ol aria-label="results">
|
||||||
|
{items.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
{v.id}
|
||||||
|
<button onClick={() => onOpen(v)}>open {v.id}</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
|
||||||
|
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
|
||||||
|
const player = vi.hoisted(() => ({
|
||||||
|
watchOnUnmount: false,
|
||||||
|
flush: null as Promise<unknown> | null,
|
||||||
|
}));
|
||||||
|
vi.mock("./PlayerModal", async () => {
|
||||||
|
const { useEffect } = await import("react");
|
||||||
|
const { trackProgressFlush } = await import("../lib/progressCache");
|
||||||
|
return {
|
||||||
|
default: function Player({
|
||||||
|
video,
|
||||||
|
onState,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
video: { id: string };
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (player.watchOnUnmount) onState(video.id, "watched");
|
||||||
|
if (player.flush) trackProgressFlush(player.flush);
|
||||||
|
},
|
||||||
|
[video.id, onState],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => onState(video.id, "watched")}>mark</button>
|
||||||
|
<button onClick={onClose}>close</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import Feed from "./Feed";
|
||||||
|
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { patchCachedProgress } from "../lib/progressCache";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
player.watchOnUnmount = false;
|
||||||
|
player.flush = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILTERS: FeedFilters = {
|
||||||
|
tags: [],
|
||||||
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
|
channelIds: ["UCx"],
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = (status: string, position = 0) =>
|
||||||
|
({
|
||||||
|
id: "v1",
|
||||||
|
title: "v1",
|
||||||
|
channel_id: "UCx",
|
||||||
|
channel_title: "Channel",
|
||||||
|
status,
|
||||||
|
position_seconds: position,
|
||||||
|
duration_seconds: 600,
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||||
|
}) as unknown as Video;
|
||||||
|
|
||||||
|
function mount({
|
||||||
|
channelScoped = true,
|
||||||
|
setFilters = () => {},
|
||||||
|
}: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) {
|
||||||
|
const feed = vi
|
||||||
|
.spyOn(api, "feed")
|
||||||
|
.mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never)
|
||||||
|
// The server after the mark: watched, so an Unwatched view leaves it out.
|
||||||
|
.mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||||
|
const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||||
|
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Feed
|
||||||
|
filters={FILTERS}
|
||||||
|
setFilters={setFilters}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
canRead
|
||||||
|
ytSearch={null}
|
||||||
|
onYtSearch={() => {}}
|
||||||
|
onExitYtSearch={() => {}}
|
||||||
|
channelScoped={channelScoped}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return { qc, feed, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Feed — a video marked watched in the player", () => {
|
||||||
|
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
|
||||||
|
const { qc } = mount();
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
fireEvent.click(await screen.findByText("mark"));
|
||||||
|
await waitFor(() => expect(screen.queryByText("open v1")).toBeNull());
|
||||||
|
// What the player does as it closes: the server's stored position, written into the cached list.
|
||||||
|
act(() => patchCachedProgress(qc, new Map([["v1", 10]])));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(screen.queryByText("open v1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the list and its count by the view's filters when the player closes", async () => {
|
||||||
|
const { feed, summary } = mount();
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
fireEvent.click(await screen.findByText("mark"));
|
||||||
|
const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length };
|
||||||
|
fireEvent.click(screen.getByText("close"));
|
||||||
|
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed));
|
||||||
|
await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary));
|
||||||
|
// The refetch is the channel's Unwatched list, and the server's answer keeps the card out.
|
||||||
|
expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({
|
||||||
|
channelIds: ["UCx"],
|
||||||
|
show: "unwatched",
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(screen.queryByText("open v1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches when the player marks the clip watched as it closes", async () => {
|
||||||
|
const { feed } = mount();
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
player.watchOnUnmount = true;
|
||||||
|
const before = feed.mock.calls.length;
|
||||||
|
fireEvent.click(await screen.findByText("close"));
|
||||||
|
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the session's writes to land before it refetches", async () => {
|
||||||
|
const { feed } = mount();
|
||||||
|
let land: (v: unknown) => void = () => {};
|
||||||
|
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
fireEvent.click(await screen.findByText("mark"));
|
||||||
|
const before = feed.mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByText("close"));
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||||
|
// A refetch now would ask a server that has not taken the mark yet.
|
||||||
|
expect(feed.mock.calls.length).toBe(before);
|
||||||
|
await act(async () => land({ ok: true }));
|
||||||
|
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the player's closing checkpoint before it refetches", async () => {
|
||||||
|
// Refetched before that write landed, the list read the older position and, landing after the
|
||||||
|
// player's patch, put it back: a reopen resumed early.
|
||||||
|
const { feed } = mount();
|
||||||
|
let land: (v: unknown) => void = () => {};
|
||||||
|
player.flush = new Promise((r) => (land = r));
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
fireEvent.click(await screen.findByText("mark"));
|
||||||
|
const before = feed.mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByText("close"));
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||||
|
expect(feed.mock.calls.length).toBe(before);
|
||||||
|
await act(async () => land(undefined));
|
||||||
|
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refetch on a close with nothing changed", async () => {
|
||||||
|
const { feed } = mount();
|
||||||
|
fireEvent.click(await screen.findByText("open v1"));
|
||||||
|
const before = feed.mock.calls.length;
|
||||||
|
fireEvent.click(await screen.findByText("close"));
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(feed.mock.calls.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed — the card menu's channel filter", () => {
|
||||||
|
it("is offered on the feed, as its own channel list, and sets it", async () => {
|
||||||
|
const setFilters = vi.fn();
|
||||||
|
mount({ channelScoped: false, setFilters });
|
||||||
|
await screen.findByText("open v1");
|
||||||
|
expect(grid.channelFilter?.ids).toEqual(["UCx"]);
|
||||||
|
grid.channelFilter?.set(["UCx", "UCy"]);
|
||||||
|
expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered on a channel page, whose view is that channel already", async () => {
|
||||||
|
mount({ channelScoped: true });
|
||||||
|
await screen.findByText("open v1");
|
||||||
|
expect(grid.channelFilter).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
||||||
// hands it: each video's position, and a button that fires the reset.
|
// hands it: each video's position, and a button that fires the reset.
|
||||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
@@ -35,6 +35,7 @@ vi.mock("./VirtualFeed", () => ({
|
|||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { patchCachedProgress } from "../lib/progressCache";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -78,30 +79,69 @@ const halfWatched = {
|
|||||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||||
} as unknown as Video;
|
} as unknown as Video;
|
||||||
|
|
||||||
|
function mount(clear: () => Promise<unknown>) {
|
||||||
|
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
||||||
|
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||||
|
vi.spyOn(api, "clearState").mockImplementation(clear as never);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Feed
|
||||||
|
filters={FILTERS}
|
||||||
|
setFilters={() => {}}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
canRead
|
||||||
|
ytSearch=""
|
||||||
|
onYtSearch={() => {}}
|
||||||
|
onExitYtSearch={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return qc;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Feed — resetting a half-watched video", () => {
|
describe("Feed — resetting a half-watched video", () => {
|
||||||
it("clears its resume position at once, not only its status", async () => {
|
it("clears its resume position at once, not only its status", async () => {
|
||||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
|
||||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
|
||||||
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
||||||
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never);
|
mount(() => new Promise(() => {}));
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<Feed
|
|
||||||
filters={FILTERS}
|
|
||||||
setFilters={() => {}}
|
|
||||||
view="cards"
|
|
||||||
setView={() => {}}
|
|
||||||
canRead
|
|
||||||
ytSearch=""
|
|
||||||
onYtSearch={() => {}}
|
|
||||||
onExitYtSearch={() => {}}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||||
fireEvent.click(screen.getByText("reset v1"));
|
fireEvent.click(screen.getByText("reset v1"));
|
||||||
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
|
||||||
|
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
|
||||||
|
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
|
||||||
|
// wrote over the 40.
|
||||||
|
const qc = mount(() => Promise.resolve({ ok: true }));
|
||||||
|
fireEvent.click(await screen.findByText("reset v1"));
|
||||||
|
await screen.findByText("v1@0");
|
||||||
|
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
|
||||||
|
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
|
||||||
|
// That refetch read the row the reset deletes: its answer put the old position back under a
|
||||||
|
// card the reset had made new, and a reopen resumed there.
|
||||||
|
let answer: (v: unknown) => void = () => {};
|
||||||
|
const qc = mount(() => new Promise(() => {}));
|
||||||
|
await screen.findByText("v1@95");
|
||||||
|
vi.mocked(api.feed).mockImplementationOnce(() => new Promise((r) => (answer = r)) as never);
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||||
|
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
|
fireEvent.click(screen.getByText("reset v1"));
|
||||||
|
await screen.findByText("v1@0");
|
||||||
|
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
|
expect(screen.getByText("v1@0")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the position back when the server refuses the reset", async () => {
|
||||||
|
mount(() => Promise.reject(new Error("down")));
|
||||||
|
fireEvent.click(await screen.findByText("reset v1"));
|
||||||
|
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import i18n from "../i18n";
|
|||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import { patchCachedProgress, progressFlushes } from "../lib/progressCache";
|
||||||
import {
|
import {
|
||||||
buildSort,
|
buildSort,
|
||||||
feedSortOptions,
|
feedSortOptions,
|
||||||
@@ -102,6 +103,22 @@ function matchesView(status: VideoStatus, show: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
|
||||||
|
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
|
||||||
|
// than its answer, and so is one sent just before it whose answer had not come back (the server may
|
||||||
|
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
|
||||||
|
type Stamped<T> = { value: T; seq: number };
|
||||||
|
type Overrides<T> = Record<string, Stamped<T>>;
|
||||||
|
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
|
||||||
|
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
|
||||||
|
// A failed write takes back only its OWN value: a later write for the same video stands.
|
||||||
|
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
|
||||||
|
if (o[id]?.seq !== seq) return o;
|
||||||
|
const next = { ...o };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||||
|
|
||||||
@@ -146,26 +163,59 @@ export default function Feed({
|
|||||||
const sortKeys = channelScoped
|
const sortKeys = channelScoped
|
||||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||||
: SORT_KEYS;
|
: SORT_KEYS;
|
||||||
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
|
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
|
||||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
|
||||||
// A reset drops the resume position too, which a status override cannot say: without this the card
|
// The write order the stamps above count in, the writes the server has answered, and which of
|
||||||
// kept its progress bar and its Continue button until the next refetch.
|
// them it had answered when the running refetch set out.
|
||||||
const [resetIds, setResetIds] = useState<Record<string, true>>({});
|
const writeSeq = useRef(0);
|
||||||
|
const answered = useRef(new Set<number>());
|
||||||
|
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
|
||||||
|
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
|
||||||
|
// before the server took the reset puts the old position back: written again on its landing.
|
||||||
|
const resets = useRef(new Map<string, number>());
|
||||||
// The open player: which video and where to start (null = resume from saved position).
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const openVideo = useCallback(
|
// What changed state while the player was open, and the writes still in flight: closing it
|
||||||
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||||
[],
|
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
|
||||||
);
|
// and the cache stayed the pre-session server's until a reload (owner).
|
||||||
|
const playerSession = useRef({
|
||||||
|
open: false,
|
||||||
|
changed: false,
|
||||||
|
pending: new Set<Promise<unknown>>(),
|
||||||
|
});
|
||||||
|
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
|
||||||
|
playerSession.current.open = true;
|
||||||
|
// Its player writes the next position: a reset no longer speaks for it.
|
||||||
|
resets.current.delete(video.id);
|
||||||
|
setActiveVideo({ video, startAt });
|
||||||
|
}, []);
|
||||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||||
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
||||||
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
||||||
// debounced volume write on each pass.
|
// debounced volume write on each pass.
|
||||||
const closePlayer = useCallback(() => setActiveVideo(null), []);
|
const closePlayer = useCallback(() => setActiveVideo(null), []);
|
||||||
|
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
|
||||||
|
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
|
||||||
|
// every unmount cleanup before any effect's setup, so here that write is already in the session,
|
||||||
|
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
|
||||||
|
// list read the older position and, landing after the player's patch, put it back.
|
||||||
|
const playerOpen = activeVideo !== null;
|
||||||
|
useEffect(() => {
|
||||||
|
const session = playerSession.current;
|
||||||
|
if (playerOpen || !session.open) return;
|
||||||
|
session.open = false;
|
||||||
|
if (!session.changed) return;
|
||||||
|
session.changed = false;
|
||||||
|
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
|
});
|
||||||
|
}, [playerOpen, qc]);
|
||||||
|
|
||||||
const ytActive = !!ytSearch;
|
const ytActive = !!ytSearch;
|
||||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||||
@@ -244,22 +294,31 @@ export default function Feed({
|
|||||||
setOverridesFor(filters);
|
setOverridesFor(filters);
|
||||||
setOverrides({});
|
setOverrides({});
|
||||||
setSavedOverrides({});
|
setSavedOverrides({});
|
||||||
setResetIds({});
|
|
||||||
}
|
}
|
||||||
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
|
||||||
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
|
||||||
// Only a real refetch (page count doesn't grow) is authoritative.
|
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
|
||||||
const pagesLenRef = useRef(0);
|
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
|
||||||
const pagesLen = query.data?.pages?.length ?? 0;
|
// And only the overrides whose write it had answered when it set out (`Stamped`).
|
||||||
|
const refetching = query.isFetching && !query.isFetchingNextPage;
|
||||||
|
const wasRefetching = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appended = pagesLen > pagesLenRef.current;
|
const started = !wasRefetching.current && refetching;
|
||||||
pagesLenRef.current = pagesLen;
|
const landed = wasRefetching.current && !refetching && query.status === "success";
|
||||||
if (!appended) {
|
wasRefetching.current = refetching;
|
||||||
setOverrides({});
|
if (started) answeredAtRefetch.current = new Set(answered.current);
|
||||||
setSavedOverrides({});
|
if (!landed) return;
|
||||||
setResetIds({});
|
const known = answeredAtRefetch.current;
|
||||||
|
setOverrides((o) => unconfirmed(o, known));
|
||||||
|
setSavedOverrides((o) => unconfirmed(o, known));
|
||||||
|
for (const seq of known) answered.current.delete(seq);
|
||||||
|
const zero = new Map<string, number>();
|
||||||
|
for (const [id, seq] of resets.current) {
|
||||||
|
if (known.has(seq)) resets.current.delete(id);
|
||||||
|
else zero.set(id, 0);
|
||||||
}
|
}
|
||||||
}, [query.dataUpdatedAt, pagesLen]);
|
patchCachedProgress(qc, zero);
|
||||||
|
}, [refetching, query.status, qc]);
|
||||||
|
|
||||||
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||||
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||||
@@ -282,19 +341,28 @@ export default function Feed({
|
|||||||
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
||||||
const onState = useCallback(
|
const onState = useCallback(
|
||||||
(id: string, status: VideoStatus) => {
|
(id: string, status: VideoStatus) => {
|
||||||
setOverrides((o) => ({ ...o, [id]: status }));
|
const seq = ++writeSeq.current;
|
||||||
|
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
|
||||||
|
const write = api.setState(id, status);
|
||||||
|
const session = playerSession.current;
|
||||||
|
if (session.open) {
|
||||||
|
session.changed = true;
|
||||||
|
session.pending.add(write);
|
||||||
|
const settled = () => void session.pending.delete(write);
|
||||||
|
write.then(settled, settled);
|
||||||
|
}
|
||||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||||
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||||
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
||||||
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
||||||
// the API is unreachable).
|
// the API is unreachable).
|
||||||
api
|
write
|
||||||
.setState(id, status)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
||||||
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
||||||
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
||||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||||
|
answered.current.add(seq);
|
||||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
if (status === "hidden") {
|
if (status === "hidden") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
@@ -340,11 +408,7 @@ export default function Feed({
|
|||||||
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||||
// a card that silently snaps back reads as a misclick.
|
// a card that silently snaps back reads as a misclick.
|
||||||
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
||||||
setOverrides((o) => {
|
setOverrides((o) => withoutWrite(o, id, seq));
|
||||||
const next = { ...o };
|
|
||||||
delete next[id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
@@ -354,24 +418,27 @@ export default function Feed({
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
||||||
// un-watch toggle can't clear an in-progress position, this can.
|
// un-watch toggle can't clear an in-progress position, this can.
|
||||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
const seq = ++writeSeq.current;
|
||||||
setResetIds((r) => ({ ...r, [id]: true }));
|
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
|
||||||
|
// The position goes into the cached lists, where the player's own closing patch writes the
|
||||||
|
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
|
||||||
|
// checkpoint wrote over the progress just made.
|
||||||
|
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
|
||||||
|
patchCachedProgress(qc, new Map([[id, 0]]));
|
||||||
|
resets.current.set(id, seq);
|
||||||
api
|
api
|
||||||
.clearState(id)
|
.clearState(id)
|
||||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
.then(() => {
|
||||||
|
answered.current.add(seq);
|
||||||
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||||
setOverrides((o) => {
|
setOverrides((o) => withoutWrite(o, id, seq));
|
||||||
const next = { ...o };
|
if (resets.current.get(id) !== seq) return;
|
||||||
delete next[id];
|
resets.current.delete(id);
|
||||||
return next;
|
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
|
||||||
});
|
|
||||||
setResetIds((r) => {
|
|
||||||
const next = { ...r };
|
|
||||||
delete next[id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
@@ -379,9 +446,11 @@ export default function Feed({
|
|||||||
|
|
||||||
const onToggleSave = useCallback(
|
const onToggleSave = useCallback(
|
||||||
(id: string, saved: boolean) => {
|
(id: string, saved: boolean) => {
|
||||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
const seq = ++writeSeq.current;
|
||||||
|
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
|
||||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
answered.current.add(seq);
|
||||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||||
})
|
})
|
||||||
@@ -392,7 +461,7 @@ export default function Feed({
|
|||||||
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
|
setSavedOverrides((o) => withoutWrite(o, id, seq));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
@@ -409,12 +478,11 @@ export default function Feed({
|
|||||||
list
|
list
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const status = overrides[v.id]; // read once so the narrowing sticks
|
const status = overrides[v.id]; // read once so the narrowing sticks
|
||||||
return status ? { ...v, status } : v;
|
return status ? { ...v, status: status.value } : v;
|
||||||
})
|
})
|
||||||
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
|
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const saved = savedOverrides[v.id];
|
const saved = savedOverrides[v.id];
|
||||||
return saved !== undefined ? { ...v, saved } : v;
|
return saved ? { ...v, saved: saved.value } : v;
|
||||||
});
|
});
|
||||||
const merged: Video[] = withOverrides(loaded);
|
const merged: Video[] = withOverrides(loaded);
|
||||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||||
@@ -878,6 +946,15 @@ export default function Feed({
|
|||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
|
// A channel page's view is the channel already: no row there to narrow it to one.
|
||||||
|
channelFilter={
|
||||||
|
channelScoped
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
ids: filters.channelIds,
|
||||||
|
set: (channelIds) => setFilters({ ...filters, channelIds }),
|
||||||
|
}
|
||||||
|
}
|
||||||
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||||
// re-request it once a round-trip for as long as the end is in view. The row
|
// re-request it once a round-trip for as long as the end is in view. The row
|
||||||
// below says so and retries on a click.
|
// below says so and retries on a click.
|
||||||
|
|||||||
@@ -663,7 +663,7 @@ describe("Home", () => {
|
|||||||
comfort: [],
|
comfort: [],
|
||||||
});
|
});
|
||||||
mount();
|
mount();
|
||||||
fireEvent.click(await screen.findByText("A Show"));
|
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
|
||||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
|
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -678,11 +678,24 @@ describe("Home", () => {
|
|||||||
comfort: [],
|
comfort: [],
|
||||||
});
|
});
|
||||||
mount();
|
mount();
|
||||||
fireEvent.click(await screen.findByText("A Show"));
|
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
|
||||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
|
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
|
||||||
expect(setPage).toHaveBeenCalledWith("plex");
|
expect(setPage).toHaveBeenCalledWith("plex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a show tile's title opens the show page; its picture plays on", async () => {
|
||||||
|
discover.mockResolvedValue({
|
||||||
|
budget: "any",
|
||||||
|
hero: null,
|
||||||
|
continue_watching: [showCard()],
|
||||||
|
comfort: [],
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByText("A Show"), { detail: 1 });
|
||||||
|
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" }));
|
||||||
|
expect(setJumpTo).not.toHaveBeenCalledWith(expect.objectContaining({ type: "play" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
|
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
|
||||||
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
|
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
|
||||||
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
|
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
|
||||||
|
|||||||
@@ -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 }]
|
||||||
@@ -371,6 +371,7 @@ function Rail({
|
|||||||
onAdd={plexAdd}
|
onAdd={plexAdd}
|
||||||
next={c.next?.plex}
|
next={c.next?.plex}
|
||||||
onInfo={() => onInfo(c)}
|
onInfo={() => onInfo(c)}
|
||||||
|
onPage={() => onInfo(c)}
|
||||||
menuProps={menuFor[i]}
|
menuProps={menuFor[i]}
|
||||||
menuButtonProps={buttonFor[i]}
|
menuButtonProps={buttonFor[i]}
|
||||||
menuOpen={menu.open && menu.target === c}
|
menuOpen={menu.open && menu.target === c}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -424,23 +424,100 @@ describe("PlayerModal — the description", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
|
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
|
||||||
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
|
// width (owner): the panel's transport row and Shift+← / → step on their own.
|
||||||
describe("PlayerModal — the side arrows", () => {
|
describe("PlayerModal — no side strips", () => {
|
||||||
it("stand inside the row's gutter", () => {
|
it("steps from the panel only, and the card keeps no gutter for strips", () => {
|
||||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
const A = vid("A", 0);
|
const A = vid("A", 0);
|
||||||
const B = vid("B", 0);
|
const B = vid("B", 0);
|
||||||
renderModal(
|
renderModal(
|
||||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
|
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
|
||||||
(b) => b.className.includes("w-12"),
|
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
|
||||||
)!;
|
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||||
expect(arrow).toBeTruthy();
|
expect(row.className).not.toMatch(/px-14/);
|
||||||
expect(arrow.className).toMatch(/\bright-0\b/);
|
});
|
||||||
expect(arrow.className).not.toMatch(/left-full/);
|
|
||||||
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
|
it("closes from one icon-only button", () => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
const close = screen.getByTestId("player-close");
|
||||||
|
expect(close.getAttribute("aria-label")).toBe("player.close");
|
||||||
|
expect(close.textContent).toBe("");
|
||||||
|
fireEvent.click(close);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -465,9 +542,7 @@ describe("PlayerModal — fits the window's height", () => {
|
|||||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||||
expect(row.style.maxWidth).toBe(
|
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
|
||||||
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -485,8 +560,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 () => {
|
||||||
@@ -510,13 +596,55 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
// The description is shown outright; there is no ⓘ to open it.
|
// The description is shown outright; there is no ⓘ to open it.
|
||||||
expect(await screen.findByText("A long description")).toBeTruthy();
|
expect(await screen.findByText("A long description")).toBeTruthy();
|
||||||
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
|
||||||
// The row's width: the video at the window's height, plus the panel and the side strips.
|
// The row's width: the video at the window's height, plus the panel.
|
||||||
expect(card.parentElement!.style.maxWidth).toBe(
|
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||||
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
|
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
|
||||||
);
|
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
|
||||||
|
expect(name.className).toMatch(/\bmin-w-0\b/);
|
||||||
|
expect(name.className).toMatch(/\bflex-1\b/);
|
||||||
|
expect(name.className).not.toMatch(/\bshrink-0\b/);
|
||||||
|
// The description is cut to three lines, with a More once it is cut.
|
||||||
|
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
|
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
|
||||||
|
wideWindow(true);
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
const B = vid("B", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
const row = () =>
|
||||||
|
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||||
|
expect(screen.queryByTestId("player-info")).toBeNull();
|
||||||
|
const strip = screen.getByTestId("player-strip");
|
||||||
|
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
within(strip).getByRole("button", { name: "player.expandPanel" }),
|
||||||
|
);
|
||||||
|
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
|
||||||
|
// The strip keeps the way out and the stepping.
|
||||||
|
expect(within(strip).getByTestId("player-close")).toBeTruthy();
|
||||||
|
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
|
||||||
|
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||||
|
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
|
||||||
|
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
|
||||||
|
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||||
|
name: "player.expandPanel",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("player-info")).toBeTruthy();
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("button", { name: "player.collapsePanel" }),
|
||||||
|
);
|
||||||
|
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
|
||||||
wideWindow(true);
|
wideWindow(true);
|
||||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||||
description: "A long description",
|
description: "A long description",
|
||||||
@@ -537,11 +665,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
return e.defaultPrevented; // prevented = the wheel went to the volume
|
return e.defaultPrevented; // prevented = the wheel went to the volume
|
||||||
};
|
};
|
||||||
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
|
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
|
||||||
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
|
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
|
||||||
top = 400;
|
top = 400;
|
||||||
expect(spin(text, 100)).toBe(true); // at its end: the volume
|
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
|
||||||
expect(spin(text, -100)).toBe(false);
|
|
||||||
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
||||||
|
// A panel that fits has nothing to scroll: the wheel is the volume there.
|
||||||
|
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
|
||||||
|
expect(spin(text, 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
|
||||||
|
wideWindow(false);
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||||
|
const card = stage.parentElement!;
|
||||||
|
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
|
||||||
|
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
||||||
|
const spin = (el: Element) => {
|
||||||
|
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
|
||||||
|
el.dispatchEvent(e);
|
||||||
|
return e.defaultPrevented;
|
||||||
|
};
|
||||||
|
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
|
||||||
|
expect(spin(stage)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A failed load read "No description." in the always-open panel — an error drawn as empty.
|
// A failed load read "No description." in the always-open panel — an error drawn as empty.
|
||||||
@@ -591,3 +741,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";
|
||||||
@@ -20,14 +21,16 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
Settings,
|
Settings,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
Trash2,
|
Trash2,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
@@ -56,7 +59,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";
|
||||||
@@ -73,7 +76,6 @@ import {
|
|||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
import { IconLink, LinkButton } from "./ui/LinkButton";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
import { SectionLabel } from "./ui/Headings";
|
||||||
@@ -104,8 +106,39 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
|||||||
// a rem width, so it grows with the text size; the video takes what is left.
|
// a rem width, so it grows with the text size; the video takes what is left.
|
||||||
const WIDE_PLAYER_PX = 1280;
|
const WIDE_PLAYER_PX = 1280;
|
||||||
const SIDE_PANEL = "20rem";
|
const SIDE_PANEL = "20rem";
|
||||||
|
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
|
||||||
|
const PANEL_STRIP = "2.75rem";
|
||||||
// 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 +148,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,
|
||||||
@@ -215,6 +251,23 @@ export default function PlayerModal({
|
|||||||
// move into a side panel beside the video, so the video takes the window's whole height — at
|
// move into a side panel beside the video, so the video takes the window's whole height — at
|
||||||
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
|
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
|
||||||
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
|
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
|
||||||
|
// The wide panel shut to a strip, for a bigger video. This player's own state: it holds while the
|
||||||
|
// queue steps on (a next video plays in this same modal), and a video opened from a card mounts a
|
||||||
|
// fresh player, which starts with the panel open (owner).
|
||||||
|
const [panelShut, setPanelShut] = useState(false);
|
||||||
|
// The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus
|
||||||
|
// goes to its counterpart, not to <body>, where the player's keys would no longer reach it.
|
||||||
|
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const refocusToggle = useRef(false);
|
||||||
|
const togglePanel = (shut: boolean) => {
|
||||||
|
refocusToggle.current = true;
|
||||||
|
setPanelShut(shut);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refocusToggle.current) return;
|
||||||
|
refocusToggle.current = false;
|
||||||
|
panelToggleRef.current?.focus();
|
||||||
|
}, [panelShut]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const card = cardRef.current;
|
const card = cardRef.current;
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -271,6 +324,8 @@ export default function PlayerModal({
|
|||||||
const navigated = currentVideoId !== active.id;
|
const navigated = currentVideoId !== active.id;
|
||||||
// Title/author of a navigated-to video, read from the player (free, no API call).
|
// Title/author of a navigated-to video, read from the player (free, no API call).
|
||||||
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||||||
|
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
|
||||||
|
const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title;
|
||||||
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
|
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
|
||||||
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
|
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
|
||||||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||||||
@@ -613,9 +668,22 @@ export default function PlayerModal({
|
|||||||
onLoadVideo: loadVideo,
|
onLoadVideo: loadVideo,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The card's width budget (its maxWidth, below): the video's, and the side strips' gutters.
|
// The card's width budget (its maxWidth, below): the video's, and the side panel's when wide.
|
||||||
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
||||||
const gutters = hasQueue ? " + 7rem" : "";
|
const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL;
|
||||||
|
// One Close, icon only: at the top right of the wide panel's head (or its strip), at the end of the
|
||||||
|
// stacked card's title row under the video.
|
||||||
|
const closeButton = (
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="player-close"
|
||||||
|
onClick={onClose}
|
||||||
|
label={t("player.close")}
|
||||||
|
title={t("player.closeEsc")}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
|
||||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||||
@@ -632,15 +700,13 @@ export default function PlayerModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
||||||
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
|
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
||||||
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
|
for a second copy of the same two buttons (owner). */}
|
||||||
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
|
|
||||||
Hidden on narrow screens where there's no room beside the card. */}
|
|
||||||
<div
|
<div
|
||||||
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
|
className="relative flex w-full max-h-full"
|
||||||
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
|
// The design width (56rem), narrowed so the 16:9 video plus the chrome under it fits the
|
||||||
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
|
// window's height less the backdrop's padding (`p-6` = 3rem).
|
||||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
|
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
|
||||||
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
||||||
// does not scroll there, always holds the whole video.
|
// does not scroll there, always holds the whole video.
|
||||||
@@ -649,27 +715,15 @@ export default function PlayerModal({
|
|||||||
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
|
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
|
||||||
style={{
|
style={{
|
||||||
maxWidth: wide
|
maxWidth: wide
|
||||||
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
|
? `calc(${fitWidth} + ${panelWidth})`
|
||||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
|
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasQueue && (
|
|
||||||
<Pressable
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goPrev();
|
|
||||||
}}
|
|
||||||
disabled={index === 0}
|
|
||||||
aria-label={t("player.previous")}
|
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
|
||||||
className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-8 w-8" />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
|
||||||
|
data-wheel-scrolls={wide ? undefined : ""}
|
||||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||||||
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||||||
}`}
|
}`}
|
||||||
@@ -677,6 +731,8 @@ export default function PlayerModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
|
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||||
|
data-wheel-volume=""
|
||||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||||||
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
||||||
} ${maximized ? "player-stage--max" : ""}`}
|
} ${maximized ? "player-stage--max" : ""}`}
|
||||||
@@ -689,39 +745,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 +836,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"
|
||||||
@@ -844,275 +914,319 @@ export default function PlayerModal({
|
|||||||
description never makes the card taller than the video. */}
|
description never makes the card taller than the video. */}
|
||||||
<div
|
<div
|
||||||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||||
style={wide ? { width: SIDE_PANEL } : undefined}
|
style={wide ? { width: panelWidth } : undefined}
|
||||||
>
|
>
|
||||||
<div
|
{wide && panelShut ? (
|
||||||
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
<div
|
||||||
data-testid="player-info"
|
data-testid="player-strip"
|
||||||
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
|
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||||||
data-wheel-scrolls={wide ? "" : undefined}
|
>
|
||||||
>
|
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
|
||||||
{hasQueue && (
|
<h2 id={titleId} className="sr-only">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
{heading}
|
||||||
<PlayerTransportControls
|
</h2>
|
||||||
variant="modal"
|
{closeButton}
|
||||||
index={index}
|
<IconButton
|
||||||
queueLength={queue!.length}
|
ref={panelToggleRef}
|
||||||
autoMode={autoMode}
|
variant="ghost"
|
||||||
loopMode={loopMode}
|
onClick={() => togglePanel(false)}
|
||||||
onPrev={goPrev}
|
label={t("player.expandPanel")}
|
||||||
onNext={goNext}
|
aria-expanded={false}
|
||||||
onCycleAuto={cycleAuto}
|
>
|
||||||
onCycleLoop={cycleLoop}
|
<PanelRightOpen />
|
||||||
/>
|
</IconButton>
|
||||||
</div>
|
{hasQueue && (
|
||||||
)}
|
<>
|
||||||
|
<span className="flex-1" />
|
||||||
<div className="p-4 sm:p-5">
|
|
||||||
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
|
||||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
|
||||||
</h2>
|
|
||||||
{!wide && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setDescAnchor}
|
|
||||||
{...descPop.getReferenceProps()}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-expanded={showDesc}
|
onClick={goPrev}
|
||||||
label={t("player.description")}
|
disabled={index === 0}
|
||||||
|
label={t("player.previous")}
|
||||||
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
>
|
>
|
||||||
<Info />
|
<SkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
<IconButton
|
||||||
{navigated && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
onClick={goNext}
|
||||||
onClick={() => loadVideo(active.id, null)}
|
disabled={index >= queue!.length - 1}
|
||||||
title={t("player.backToOriginal")}
|
label={t("player.next")}
|
||||||
icon={<ArrowLeft />}
|
title={`${t("player.next")} · Shift+→`}
|
||||||
>
|
>
|
||||||
{t("player.back")}
|
<SkipForward />
|
||||||
</Button>
|
</IconButton>
|
||||||
)}
|
</>
|
||||||
<PopoverSurface
|
)}
|
||||||
popover={descPop}
|
</div>
|
||||||
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
) : (
|
||||||
>
|
<div
|
||||||
<SectionLabel level={3} className="mb-2">
|
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||||||
{t("player.description")}
|
data-testid="player-info"
|
||||||
</SectionLabel>
|
// A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
|
||||||
<PlayerDescription
|
data-wheel-scrolls={wide ? "" : undefined}
|
||||||
ref={descFadeRef}
|
>
|
||||||
style={descFadeStyle}
|
{wide && (
|
||||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
<div className="flex items-center justify-between px-2 pt-2">
|
||||||
{...descriptionProps}
|
<IconButton
|
||||||
|
ref={panelToggleRef}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => togglePanel(true)}
|
||||||
|
label={t("player.collapsePanel")}
|
||||||
|
aria-expanded
|
||||||
|
>
|
||||||
|
<PanelRightClose />
|
||||||
|
</IconButton>
|
||||||
|
{closeButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasQueue && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
||||||
|
<PlayerTransportControls
|
||||||
|
variant="modal"
|
||||||
|
index={index}
|
||||||
|
queueLength={queue!.length}
|
||||||
|
autoMode={autoMode}
|
||||||
|
loopMode={loopMode}
|
||||||
|
onPrev={goPrev}
|
||||||
|
onNext={goNext}
|
||||||
|
onCycleAuto={cycleAuto}
|
||||||
|
onCycleLoop={cycleLoop}
|
||||||
/>
|
/>
|
||||||
</PopoverSurface>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-testid="player-close"
|
|
||||||
onClick={onClose}
|
|
||||||
title={t("player.closeEsc")}
|
|
||||||
>
|
|
||||||
{t("player.close")}
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
<div className="p-4 sm:p-5">
|
||||||
When navigated to a linked video we only have its author (from the player),
|
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
||||||
so we show that as plain text and hide feed-video-specific bits. */}
|
right (wide, it heads the panel). */}
|
||||||
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
|
<div className="flex items-start gap-3">
|
||||||
{!navigated && (
|
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||||
<Avatar
|
{heading}
|
||||||
src={active.channel_thumbnail}
|
</h2>
|
||||||
fallback={active.channel_title ?? ""}
|
{!wide && (
|
||||||
className="w-9 h-9 rounded-full shrink-0"
|
<IconButton
|
||||||
/>
|
ref={setDescAnchor}
|
||||||
)}
|
{...descPop.getReferenceProps()}
|
||||||
{navigated ? (
|
variant="ghost"
|
||||||
detail.data?.channel_id ? (
|
aria-expanded={showDesc}
|
||||||
<a
|
label={t("player.description")}
|
||||||
href={channelYouTubeUrl(detail.data.channel_id)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-medium hover:text-accent shrink-0"
|
|
||||||
>
|
>
|
||||||
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
<Info />
|
||||||
</a>
|
</IconButton>
|
||||||
) : (
|
)}
|
||||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
{navigated && (
|
||||||
)
|
<Button
|
||||||
) : (
|
variant="ghost"
|
||||||
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
size="sm"
|
||||||
<ChannelNameLink
|
onClick={() => loadVideo(active.id, null)}
|
||||||
channelId={active.channel_id}
|
title={t("player.backToOriginal")}
|
||||||
title={active.channel_title}
|
icon={<ArrowLeft />}
|
||||||
onOpenInApp={() => {
|
>
|
||||||
// openChannel closes this player first and pushes only once its
|
{t("player.back")}
|
||||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
</Button>
|
||||||
// and clicking the name merely closes the player
|
)}
|
||||||
// (lib/history.closeOverlaysThenNavigate).
|
<PopoverSurface
|
||||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
popover={descPop}
|
||||||
}}
|
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||||||
className="font-medium hover:text-accent truncate text-left"
|
>
|
||||||
|
<SectionLabel level={3} className="mb-2">
|
||||||
|
{t("player.description")}
|
||||||
|
</SectionLabel>
|
||||||
|
<PlayerDescription
|
||||||
|
ref={descFadeRef}
|
||||||
|
style={descFadeStyle}
|
||||||
|
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||||
|
{...descriptionProps}
|
||||||
/>
|
/>
|
||||||
<IconLink
|
</PopoverSurface>
|
||||||
href={active.channel_url}
|
{!wide && closeButton}
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noreferrer"
|
|
||||||
label={t("channels.row.openOnYouTube")}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<ExternalLink />
|
|
||||||
</IconLink>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!navigated ? (
|
|
||||||
<div
|
|
||||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
|
||||||
>
|
|
||||||
{active.view_count != null && (
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{t("player.views", {
|
|
||||||
count: active.view_count,
|
|
||||||
formattedCount: formatCompact(active.view_count, lang),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{relativeTime(active.published_at, lang, t)}
|
|
||||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
|
||||||
</span>
|
|
||||||
{active.duration_seconds != null && (
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{formatDuration(active.duration_seconds)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{active.live_status === "was_live" && (
|
|
||||||
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
|
||||||
{t("player.stream")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Linked video's stats come from the (already-fetched) video detail.
|
|
||||||
<div
|
|
||||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
|
||||||
>
|
|
||||||
{detail.data?.view_count != null && (
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{t("player.views", {
|
|
||||||
count: detail.data.view_count,
|
|
||||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail.data?.published_at && (
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
|
||||||
{fullDate(detail.data.published_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail.data?.duration_seconds != null && (
|
|
||||||
<span>
|
|
||||||
{metaSep}
|
|
||||||
{formatDuration(detail.data.duration_seconds)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
{/* Channel + meta on one line, with the actions pushed to the right.
|
||||||
|
When navigated to a linked video we only have its author (from the player),
|
||||||
|
so we show that as plain text and hide feed-video-specific bits.
|
||||||
|
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
|
||||||
|
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
|
||||||
|
one-word column between the name and the buttons. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||||||
|
{!navigated && (
|
||||||
|
<Avatar
|
||||||
|
src={active.channel_thumbnail}
|
||||||
|
fallback={active.channel_title ?? ""}
|
||||||
|
className="w-9 h-9 rounded-full shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{navigated ? (
|
||||||
|
detail.data?.channel_id ? (
|
||||||
|
<a
|
||||||
|
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={`font-medium hover:text-accent ${wide ? "min-w-0 truncate" : "shrink-0"}`}
|
||||||
|
>
|
||||||
|
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={`font-medium ${wide ? "min-w-0 truncate" : "shrink-0"}`}>
|
||||||
|
{liveData?.author ?? ""}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// Wide, the name takes what the avatar leaves of the panel's line and is cut
|
||||||
|
// there: unshrinkable, a long one ran the 20rem panel into a sideways scroll.
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
|
||||||
|
>
|
||||||
|
<ChannelNameLink
|
||||||
|
channelId={active.channel_id}
|
||||||
|
title={active.channel_title}
|
||||||
|
onOpenInApp={() => {
|
||||||
|
// openChannel closes this player first and pushes only once its
|
||||||
|
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||||
|
// and clicking the name merely closes the player
|
||||||
|
// (lib/history.closeOverlaysThenNavigate).
|
||||||
|
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||||
|
}}
|
||||||
|
className="font-medium hover:text-accent truncate text-left"
|
||||||
|
/>
|
||||||
|
<IconLink
|
||||||
|
href={active.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
label={t("channels.row.openOnYouTube")}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ExternalLink />
|
||||||
|
</IconLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!navigated ? (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||||
|
>
|
||||||
|
{active.view_count != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{t("player.views", {
|
||||||
|
count: active.view_count,
|
||||||
|
formattedCount: formatCompact(active.view_count, lang),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{relativeTime(active.published_at, lang, t)}
|
||||||
|
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||||
|
</span>
|
||||||
|
{active.duration_seconds != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{formatDuration(active.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{active.live_status === "was_live" && (
|
||||||
|
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("player.stream")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Linked video's stats come from the (already-fetched) video detail.
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||||
|
>
|
||||||
|
{detail.data?.view_count != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{t("player.views", {
|
||||||
|
count: detail.data.view_count,
|
||||||
|
formattedCount: formatCompact(detail.data.view_count, lang),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.data?.published_at && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||||
|
{fullDate(detail.data.published_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.data?.duration_seconds != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{formatDuration(detail.data.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
||||||
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
||||||
the only fullscreen button previously reachable in the windowed video was YouTube's
|
the only fullscreen button previously reachable in the windowed video was YouTube's
|
||||||
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
|
|
||||||
variant="outline"
|
|
||||||
size="md"
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
label={t("player.fullscreen")}
|
|
||||||
title={`${t("player.fullscreen")} · F`}
|
|
||||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||||
className={wide ? undefined : "ml-auto"}
|
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||||
>
|
<IconButton
|
||||||
<Maximize />
|
variant="outline"
|
||||||
</IconButton>
|
size="md"
|
||||||
)}
|
onClick={toggleFullscreen}
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
label={t("player.fullscreen")}
|
||||||
{!navigated && (
|
title={`${t("player.fullscreen")} · F`}
|
||||||
<DownloadButton
|
>
|
||||||
videoId={active.id}
|
<Maximize />
|
||||||
title={active.title}
|
</IconButton>
|
||||||
variant="outline"
|
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||||
size="md"
|
<DownloadButton
|
||||||
/>
|
videoId={active.id}
|
||||||
)}
|
title={active.title}
|
||||||
{!navigated && onRemoveFromList && (
|
variant="outline"
|
||||||
<IconButton
|
size="md"
|
||||||
variant="outline-danger"
|
/>
|
||||||
size="md"
|
{onRemoveFromList && (
|
||||||
onClick={() => void removeFromList()}
|
<IconButton
|
||||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
variant="outline-danger"
|
||||||
// Download / Mark watched reads as "delete this video".
|
size="md"
|
||||||
label={t("playlists.removeItemOf", { title: active.title })}
|
onClick={() => void removeFromList()}
|
||||||
title={t("playlists.removeItem")}
|
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||||
>
|
// Download / Mark watched reads as "delete this video".
|
||||||
<Trash2 />
|
label={t("playlists.removeItemOf", { title: active.title })}
|
||||||
</IconButton>
|
title={t("playlists.removeItem")}
|
||||||
)}
|
>
|
||||||
{!navigated && (
|
<Trash2 />
|
||||||
<Button
|
</IconButton>
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
<Button
|
||||||
pressed={watched}
|
variant="outline"
|
||||||
onClick={() => setWatched(!watched)}
|
size="sm"
|
||||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
pressed={watched}
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
onClick={() => setWatched(!watched)}
|
||||||
>
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
icon={watched ? <CheckCheck /> : <Check />}
|
||||||
</Button>
|
>
|
||||||
|
{watched ? t("player.watched") : t("player.markWatched")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wide && (
|
||||||
|
<section className="mt-5" data-testid="player-description">
|
||||||
|
<SectionLabel level={3} className="mb-2">
|
||||||
|
{t("player.description")}
|
||||||
|
</SectionLabel>
|
||||||
|
<PlayerDescription
|
||||||
|
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
||||||
|
clamp
|
||||||
|
{...descriptionProps}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
|
||||||
<section className="mt-5" data-testid="player-description">
|
|
||||||
<SectionLabel level={3} className="mb-2">
|
|
||||||
{t("player.description")}
|
|
||||||
</SectionLabel>
|
|
||||||
<PlayerDescription
|
|
||||||
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
|
||||||
{...descriptionProps}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasQueue && (
|
|
||||||
<Pressable
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goNext();
|
|
||||||
}}
|
|
||||||
disabled={index >= queue!.length - 1}
|
|
||||||
aria-label={t("player.next")}
|
|
||||||
title={`${t("player.next")} · Shift+→`}
|
|
||||||
className="absolute right-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-8 w-8" />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
||||||
Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the
|
Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the
|
||||||
|
|||||||
@@ -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,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
|
||||||
|
// episode and opens the player on it; a started show's Restart opens it from the start; its title
|
||||||
|
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./PlexPlayer", () => ({
|
||||||
|
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
|
||||||
|
<div>
|
||||||
|
player {itemId} {fromStart ? "from the start" : "resumed"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("./plexViews", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./plexViews")>()),
|
||||||
|
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
|
||||||
|
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PlexBrowse from "./PlexBrowse";
|
||||||
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const show: PlexCard = {
|
||||||
|
id: "sh",
|
||||||
|
type: "show",
|
||||||
|
title: "Grimm",
|
||||||
|
thumb: "",
|
||||||
|
status: "in_progress",
|
||||||
|
resumable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mount(episodes: PlexCard[] = []) {
|
||||||
|
vi.spyOn(api, "plexLibrary").mockResolvedValue({
|
||||||
|
items: [show],
|
||||||
|
total: 1,
|
||||||
|
offset: 0,
|
||||||
|
episodes,
|
||||||
|
} as never);
|
||||||
|
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
|
||||||
|
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
|
||||||
|
});
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexBrowse />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return { next };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlexBrowse — a show's card in the grid", () => {
|
||||||
|
it("plays the episode the server names", async () => {
|
||||||
|
const { next } = mount();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
|
||||||
|
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
|
||||||
|
expect(next).toHaveBeenCalledWith("sh", "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts that episode over from its Restart", async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
|
||||||
|
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the show's page from its title, asking nothing", async () => {
|
||||||
|
const { next } = mount();
|
||||||
|
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
|
||||||
|
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A matched episode's title opens its page, a pointer's door: its menu offers the same to the keyboard.
|
||||||
|
it("opens a matched episode's page from its menu, as its title does", async () => {
|
||||||
|
mount([{ id: "ep1", type: "episode", title: "Pilot", thumb: "", show_title: "Grimm" }]);
|
||||||
|
const section = (await screen.findByText("plex.unified.episodes")).parentElement!;
|
||||||
|
fireEvent.click(section.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||||
|
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||||
|
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||||
|
expect(await screen.findByText("info page ep1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the show's page from its ⓘ, asking nothing", async () => {
|
||||||
|
const { next } = mount();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
|
||||||
|
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
|
|||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import { usePlayShow } from "./usePlayShow";
|
||||||
import SortControl from "./ui/SortControl";
|
import SortControl from "./ui/SortControl";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import PlexDiscover from "./PlexDiscover";
|
import PlexDiscover from "./PlexDiscover";
|
||||||
@@ -328,18 +329,32 @@ export default function PlexBrowse() {
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
||||||
|
|
||||||
|
const resolveShow = usePlayShow(
|
||||||
|
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||||
|
(id) => sub.open({ kind: "show", id }),
|
||||||
|
sub.view,
|
||||||
|
);
|
||||||
|
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||||
|
function playShow(card: PlexCard, fromStart: boolean) {
|
||||||
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
void resolveShow(card, fromStart);
|
||||||
|
}
|
||||||
function onCard(card: PlexCard) {
|
function onCard(card: PlexCard) {
|
||||||
|
if (card.type === "show") return playShow(card, false);
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
sub.open({ kind: "player", id: card.id });
|
||||||
else sub.open({ kind: "player", id: card.id });
|
|
||||||
}
|
}
|
||||||
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
||||||
function onRestartCard(card: PlexCard) {
|
function onRestartCard(card: PlexCard) {
|
||||||
|
if (card.type === "show") return playShow(card, true);
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||||
}
|
}
|
||||||
|
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
|
||||||
|
// and a card title's link).
|
||||||
function onInfo(card: PlexCard) {
|
function onInfo(card: PlexCard) {
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
|
||||||
rememberInfoScroll(0); // fresh info page starts at the top
|
rememberInfoScroll(0); // fresh info page starts at the top
|
||||||
sub.open({ kind: "info", id: card.id });
|
sub.open({ kind: "info", id: card.id });
|
||||||
}
|
}
|
||||||
@@ -377,8 +392,9 @@ export default function PlexBrowse() {
|
|||||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||||
menu.open,
|
menu.open,
|
||||||
);
|
);
|
||||||
|
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
|
||||||
const epActions = usePlexCardActions(
|
const epActions = usePlexCardActions(
|
||||||
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
|
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||||
epMenu.open,
|
epMenu.open,
|
||||||
);
|
);
|
||||||
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||||
@@ -461,6 +477,20 @@ export default function PlexBrowse() {
|
|||||||
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
|
// Up to the show or season: Back returns to this page where it was (as from a cast row).
|
||||||
|
onOpenShow={(showId) => {
|
||||||
|
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||||
|
sub.open({ kind: "show", id: showId });
|
||||||
|
}}
|
||||||
|
onOpenSeason={(showId, seasonId) => {
|
||||||
|
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||||
|
sub.open({ kind: "season", showId, seasonId });
|
||||||
|
}}
|
||||||
|
// A collection strip's title: another info page, from its top.
|
||||||
|
onOpenInfo={(id) => {
|
||||||
|
rememberInfoScroll(0);
|
||||||
|
sub.open({ kind: "info", id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +522,7 @@ export default function PlexBrowse() {
|
|||||||
}
|
}
|
||||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||||
|
onPlayShow={playShow}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
/>
|
/>
|
||||||
@@ -504,6 +535,7 @@ export default function PlexBrowse() {
|
|||||||
showId={showId}
|
showId={showId}
|
||||||
seasonId={seasonId}
|
seasonId={seasonId}
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
|
onInfoCard={onInfo}
|
||||||
onPlay={(epRk, queue, fromStart) =>
|
onPlay={(epRk, queue, fromStart) =>
|
||||||
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
||||||
}
|
}
|
||||||
@@ -707,6 +739,7 @@ export default function PlexBrowse() {
|
|||||||
navIndex={i}
|
navIndex={i}
|
||||||
card={c}
|
card={c}
|
||||||
onOpen={() => onCard(c)}
|
onOpen={() => onCard(c)}
|
||||||
|
onPage={onInfo}
|
||||||
onRestart={() => onRestartCard(c)}
|
onRestart={() => onRestartCard(c)}
|
||||||
onToggleWatched={toggleWatched}
|
onToggleWatched={toggleWatched}
|
||||||
onAdd={actions.add}
|
onAdd={actions.add}
|
||||||
@@ -730,6 +763,7 @@ export default function PlexBrowse() {
|
|||||||
ep={ep}
|
ep={ep}
|
||||||
withShowTitle
|
withShowTitle
|
||||||
onPlay={() => onCard(ep)}
|
onPlay={() => onCard(ep)}
|
||||||
|
onPage={() => onInfo(ep)}
|
||||||
onRestart={() => onRestartCard(ep)}
|
onRestart={() => onRestartCard(ep)}
|
||||||
onToggleWatched={() => toggleWatched(ep)}
|
onToggleWatched={() => toggleWatched(ep)}
|
||||||
onAdd={() => epActions.add(ep)}
|
onAdd={() => epActions.add(ep)}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -431,6 +431,7 @@ export default function PlexCollectionView({
|
|||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onPage={onInfoCard}
|
||||||
onRestart={() => onRestartCard(c)}
|
onRestart={() => onRestartCard(c)}
|
||||||
onToggleWatched={toggleWatched}
|
onToggleWatched={toggleWatched}
|
||||||
onAdd={actions.add}
|
onAdd={actions.add}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexDiscover from "./PlexDiscover";
|
import PlexDiscover from "./PlexDiscover";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import type { PlexCard } from "../lib/api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
|
|||||||
expect.objectContaining({ genres: [] }),
|
expect.objectContaining({ genres: [] }),
|
||||||
]);
|
]);
|
||||||
expect(keys).toContainEqual([
|
expect(keys).toContainEqual([
|
||||||
"plex-surprise",
|
"plex-discover",
|
||||||
|
"surprise",
|
||||||
"both",
|
"both",
|
||||||
"en",
|
"en",
|
||||||
expect.objectContaining({ genres: [] }),
|
expect.objectContaining({ genres: [] }),
|
||||||
@@ -218,6 +221,103 @@ describe("PlexDiscover", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||||
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
||||||
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
||||||
|
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
|
||||||
|
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
|
||||||
|
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
|
||||||
|
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
|
||||||
|
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
|
||||||
|
plexDiscover.mockResolvedValue(fullPayload);
|
||||||
|
let status: PlexCard["status"] = "new";
|
||||||
|
plexSurprise.mockImplementation(
|
||||||
|
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||||
|
Promise.resolve({
|
||||||
|
scope: "both",
|
||||||
|
items: o.keep?.length
|
||||||
|
? [{ ...card("sp1", "Random Pick"), status }]
|
||||||
|
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const ui = (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexDiscover
|
||||||
|
scope="both"
|
||||||
|
onOpenCard={noop}
|
||||||
|
onRestartCard={() => {}}
|
||||||
|
onInfoCard={noop}
|
||||||
|
onGenre={noop}
|
||||||
|
onSeeAllRecent={noop}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const first = render(ui);
|
||||||
|
await screen.findByText("Pick");
|
||||||
|
first.unmount();
|
||||||
|
render(ui);
|
||||||
|
await screen.findByText("Random Pick");
|
||||||
|
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
|
||||||
|
status = "watched";
|
||||||
|
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||||
|
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
|
||||||
|
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
|
||||||
|
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
|
||||||
|
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
|
||||||
|
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
|
||||||
|
expect(entry?.options.gcTime).toBe(Infinity);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||||
|
await screen.findByText("Pick");
|
||||||
|
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
|
||||||
|
exclude: ["sp1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻
|
||||||
|
// deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back
|
||||||
|
// and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles.
|
||||||
|
it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => {
|
||||||
|
plexDiscover.mockResolvedValue(fullPayload);
|
||||||
|
const answers: ((v: unknown) => void)[] = [];
|
||||||
|
const rereads: ((v: unknown) => void)[] = [];
|
||||||
|
plexSurprise.mockImplementation(
|
||||||
|
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||||
|
plexSurprise.mock.calls.length === 1
|
||||||
|
? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] })
|
||||||
|
: new Promise((r) => (o.keep ? rereads : answers).push(r)),
|
||||||
|
);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexDiscover
|
||||||
|
scope="both"
|
||||||
|
onOpenCard={noop}
|
||||||
|
onRestartCard={() => {}}
|
||||||
|
onInfoCard={noop}
|
||||||
|
onGenre={noop}
|
||||||
|
onSeeAllRecent={noop}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("Old Pick");
|
||||||
|
const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" });
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||||
|
await waitFor(() => expect(rereads).toHaveLength(1));
|
||||||
|
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||||
|
await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] }));
|
||||||
|
fireEvent.click(button());
|
||||||
|
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||||
|
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||||
|
await waitFor(() => expect(answers).toHaveLength(2));
|
||||||
|
const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1];
|
||||||
|
expect(last?.[4]).toEqual({ exclude: ["sp1"] });
|
||||||
|
// The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it.
|
||||||
|
await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] }));
|
||||||
|
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||||
|
await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] }));
|
||||||
|
await screen.findByText("New Pick");
|
||||||
|
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the empty message when every rail is empty", async () => {
|
it("shows the empty message when every rail is empty", async () => {
|
||||||
@@ -244,8 +344,8 @@ describe("PlexDiscover", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("puts a Surprise card back when its watched toggle fails", async () => {
|
it("puts a Surprise card back when its watched toggle fails", async () => {
|
||||||
// The resync refetches the rails but not the Surprise draw (that would reshuffle it), so a
|
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
|
||||||
// refused toggle stayed flipped there — the sibling of the collection page's fix.
|
// left flipped until that read lands — the sibling of the collection page's fix.
|
||||||
plexDiscover.mockResolvedValue({
|
plexDiscover.mockResolvedValue({
|
||||||
...fullPayload,
|
...fullPayload,
|
||||||
continue_watching: [],
|
continue_watching: [],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode } from "react";
|
import { useRef, useState, type ReactNode } 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 { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
@@ -78,6 +78,7 @@ function CardRail({
|
|||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onPage={onInfoCard}
|
||||||
onRestart={() => onRestartCard(c)}
|
onRestart={() => onRestartCard(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
onAdd={actions.add}
|
onAdd={actions.add}
|
||||||
@@ -119,11 +120,46 @@ export default function PlexDiscover({
|
|||||||
queryFn: () => api.plexDiscover(scope, lang, filters),
|
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||||
});
|
});
|
||||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||||
|
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
|
||||||
|
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
|
||||||
|
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
|
||||||
|
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
|
||||||
|
const surpriseKey = qk.plexSurprise(scope, lang, filters);
|
||||||
|
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
|
||||||
|
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
|
||||||
|
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
|
||||||
|
// answer does not stop the spinner of the deal that replaced it.
|
||||||
|
const redraw = useRef(false);
|
||||||
|
const lastDeal = useRef<Promise<unknown> | null>(null);
|
||||||
|
const [dealing, setDealing] = useState(false);
|
||||||
const surpriseQ = useQuery({
|
const surpriseQ = useQuery({
|
||||||
queryKey: qk.plexSurprise(scope, lang, filters),
|
queryKey: surpriseKey,
|
||||||
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
queryFn: () => {
|
||||||
staleTime: 0,
|
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
|
||||||
|
(c) => c.id,
|
||||||
|
);
|
||||||
|
if (!redraw.current && shown.length > 0) {
|
||||||
|
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
|
||||||
|
}
|
||||||
|
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
|
||||||
|
lastDeal.current = deal;
|
||||||
|
const done = () => {
|
||||||
|
if (lastDeal.current !== deal) return;
|
||||||
|
redraw.current = false;
|
||||||
|
setDealing(false);
|
||||||
|
};
|
||||||
|
void deal.then(done, done);
|
||||||
|
return deal;
|
||||||
|
},
|
||||||
|
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
|
||||||
|
// a collected draw is a new one.
|
||||||
|
gcTime: Infinity,
|
||||||
});
|
});
|
||||||
|
const reshuffle = () => {
|
||||||
|
redraw.current = true;
|
||||||
|
setDealing(true);
|
||||||
|
void surpriseQ.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
||||||
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
||||||
@@ -154,8 +190,8 @@ export default function PlexDiscover({
|
|||||||
);
|
);
|
||||||
setSurpriseStatus(card, next);
|
setSurpriseStatus(card, next);
|
||||||
},
|
},
|
||||||
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
|
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
|
||||||
// toggle was left flipped there — put back in place instead.
|
// refused toggle is put back at once rather than left flipped until that read lands.
|
||||||
revert: () => setSurpriseStatus(card, card.status),
|
revert: () => setSurpriseStatus(card, card.status),
|
||||||
resync: () => {
|
resync: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
||||||
@@ -228,8 +264,10 @@ export default function PlexDiscover({
|
|||||||
cards={surprise}
|
cards={surprise}
|
||||||
action={
|
action={
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => surpriseQ.refetch()}
|
onClick={reshuffle}
|
||||||
loading={surpriseQ.isFetching}
|
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
|
||||||
|
// new draw that never came.
|
||||||
|
loading={dealing}
|
||||||
label={t("plex.discover.reshuffle")}
|
label={t("plex.discover.reshuffle")}
|
||||||
>
|
>
|
||||||
<RotateCcw />
|
<RotateCcw />
|
||||||
|
|||||||
@@ -173,6 +173,55 @@ describe("PlexInfo enriched detail (page)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("an episode page links up to its show and season", () => {
|
||||||
|
const episode: PlexItemDetail = {
|
||||||
|
...detail,
|
||||||
|
id: "e1",
|
||||||
|
kind: "episode",
|
||||||
|
title: "Pilot",
|
||||||
|
show_title: "A Show",
|
||||||
|
show_id: "s1",
|
||||||
|
season_id: "se1",
|
||||||
|
season_number: 1,
|
||||||
|
episode_number: 25,
|
||||||
|
tmdb: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("the show title opens the show, the episode number its season", () => {
|
||||||
|
const onOpenShow = vi.fn();
|
||||||
|
const onOpenSeason = vi.fn();
|
||||||
|
wrap(
|
||||||
|
<PlexInfo
|
||||||
|
onPlayItem={noop}
|
||||||
|
detail={episode}
|
||||||
|
variant="page"
|
||||||
|
onOpenShow={onOpenShow}
|
||||||
|
onOpenSeason={onOpenSeason}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "A Show" }));
|
||||||
|
expect(onOpenShow).toHaveBeenCalledWith("s1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "S1·E25" }));
|
||||||
|
expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1");
|
||||||
|
expect(screen.getByText(/— Pilot/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over the running player the header stays plain text", () => {
|
||||||
|
wrap(
|
||||||
|
<PlexInfo
|
||||||
|
onPlayItem={noop}
|
||||||
|
detail={episode}
|
||||||
|
variant="overlay"
|
||||||
|
onOpenShow={noop}
|
||||||
|
onOpenSeason={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "A Show" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull();
|
||||||
|
expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
|
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
|
||||||
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||||
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||||
@@ -362,4 +411,18 @@ describe("a collection strip's card", () => {
|
|||||||
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
|
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
|
||||||
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
|
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard.
|
||||||
|
it("opens the member's page from its menu, as its title does", async () => {
|
||||||
|
const onOpenInfo = vi.fn();
|
||||||
|
wrap(
|
||||||
|
<PlexInfo detail={withColl} variant="page" onPlayItem={vi.fn()} onOpenInfo={onOpenInfo} />,
|
||||||
|
);
|
||||||
|
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
|
||||||
|
.parentElement!;
|
||||||
|
fireEvent.click(strip.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||||
|
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||||
|
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||||
|
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAsi
|
|||||||
import {
|
import {
|
||||||
DETAIL_POSTER,
|
DETAIL_POSTER,
|
||||||
episodeLine,
|
episodeLine,
|
||||||
|
episodeName,
|
||||||
|
episodeNumbers,
|
||||||
tmdbBackdropUrl,
|
tmdbBackdropUrl,
|
||||||
useCrossfadeBackdrop,
|
useCrossfadeBackdrop,
|
||||||
useDetailAside,
|
useDetailAside,
|
||||||
@@ -186,6 +188,11 @@ type Props = {
|
|||||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||||
library?: string;
|
library?: string;
|
||||||
|
// Page variant, an episode: its show title opens the show page, its "S1·E25" the season page.
|
||||||
|
onOpenShow?: (showId: string) => void;
|
||||||
|
onOpenSeason?: (showId: string, seasonId: string) => void;
|
||||||
|
// Page variant: a collection strip's title opens that title's info page (its picture plays it).
|
||||||
|
onOpenInfo?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlexInfo({
|
export default function PlexInfo({
|
||||||
@@ -198,6 +205,9 @@ export default function PlexInfo({
|
|||||||
onOpenPerson,
|
onOpenPerson,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
library,
|
library,
|
||||||
|
onOpenShow,
|
||||||
|
onOpenSeason,
|
||||||
|
onOpenInfo,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
@@ -275,10 +285,13 @@ export default function PlexInfo({
|
|||||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Details wherever the member's title opens its page (not in the overlay): the menu is that door
|
||||||
|
// for the keyboard.
|
||||||
const colActions = usePlexCardActions(
|
const colActions = usePlexCardActions(
|
||||||
{
|
{
|
||||||
onOpen: (c) => onPlayItem(c.id),
|
onOpen: (c) => onPlayItem(c.id),
|
||||||
onRestart: (c) => onPlayItem(c.id, true),
|
onRestart: (c) => onPlayItem(c.id, true),
|
||||||
|
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
|
||||||
onToggleWatched: toggleColWatched,
|
onToggleWatched: toggleColWatched,
|
||||||
},
|
},
|
||||||
colMenu.open,
|
colMenu.open,
|
||||||
@@ -299,6 +312,12 @@ export default function PlexInfo({
|
|||||||
const asideShown = !overlay && aside.column;
|
const asideShown = !overlay && aside.column;
|
||||||
const infoTitle =
|
const infoTitle =
|
||||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||||
|
// Up from an episode: the show's title and the episode's season are links on the page (not over
|
||||||
|
// the running player, where leaving would stop it).
|
||||||
|
const showId = detail.kind === "episode" && !overlay ? detail.show_id : null;
|
||||||
|
const seasonId = detail.season_id;
|
||||||
|
const numbers = episodeNumbers(detail);
|
||||||
|
const upLink = "hover:text-accent hover:underline";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||||
@@ -312,11 +331,32 @@ export default function PlexInfo({
|
|||||||
{overlay ? (
|
{overlay ? (
|
||||||
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
||||||
) : (
|
) : (
|
||||||
<PageHeading className="text-2xl font-bold">{infoTitle}</PageHeading>
|
<PageHeading className="text-2xl font-bold">
|
||||||
|
{showId && onOpenShow && detail.show_title ? (
|
||||||
|
<Pressable selectable className={upLink} onClick={() => onOpenShow(showId)}>
|
||||||
|
{infoTitle}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
infoTitle
|
||||||
|
)}
|
||||||
|
</PageHeading>
|
||||||
)}
|
)}
|
||||||
{detail.kind === "episode" && (
|
{detail.kind === "episode" && (
|
||||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||||
{episodeLine(detail)}
|
{showId && seasonId && onOpenSeason && numbers ? (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
className={upLink}
|
||||||
|
onClick={() => onOpenSeason(showId, seasonId)}
|
||||||
|
>
|
||||||
|
{episodeName(detail)}
|
||||||
|
</Pressable>
|
||||||
|
{` — ${detail.title}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
episodeLine(detail)
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -468,7 +508,7 @@ export default function PlexInfo({
|
|||||||
|
|
||||||
{detail.summary && (
|
{detail.summary && (
|
||||||
<p
|
<p
|
||||||
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
className={`measure mt-3 whitespace-pre-line text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||||
>
|
>
|
||||||
{detail.summary}
|
{detail.summary}
|
||||||
</p>
|
</p>
|
||||||
@@ -576,6 +616,7 @@ export default function PlexInfo({
|
|||||||
shelf
|
shelf
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onPlayItem(m.id)}
|
onOpen={() => onPlayItem(m.id)}
|
||||||
|
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
|
||||||
onRestart={() => onPlayItem(m.id, true)}
|
onRestart={() => onPlayItem(m.id, true)}
|
||||||
onToggleWatched={toggleColWatched}
|
onToggleWatched={toggleColWatched}
|
||||||
onAdd={colActions.add}
|
onAdd={colActions.add}
|
||||||
|
|||||||
@@ -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 = () =>
|
||||||
@@ -154,10 +161,34 @@ describe("a Plex title's menu", () => {
|
|||||||
expect(labels()).not.toContain("card.restartTitle");
|
expect(labels()).not.toContain("card.restartTitle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a show opens its page and has no watched, add or details rows", () => {
|
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
|
||||||
setup(card({ type: "show" }));
|
// The watched toggle and the add belong to an episode, so a show has none.
|
||||||
|
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
|
||||||
|
const { handlers } = setup(card({ type: "show" }));
|
||||||
rightClick();
|
rightClick();
|
||||||
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||||
|
fireEvent.click(item("plex.play"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||||
|
);
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.openShow"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a started show continues, and starts its episode over from the next row", () => {
|
||||||
|
setup(card({ type: "show", status: "in_progress", resumable: true }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
|
||||||
|
setup(card({ type: "show", status: "in_progress", resumable: false }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()[0]).toBe("plex.play");
|
||||||
|
expect(labels()).not.toContain("card.restartTitle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the card's '…' opens the same menu, and says so", () => {
|
it("the card's '…' opens the same menu, and says so", () => {
|
||||||
@@ -223,3 +254,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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
|
|||||||
expect(v.getAttribute("src")).toBeNull();
|
expect(v.getAttribute("src")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closing the player refreshes every surface that shows watch state, the Discover rails too", async () => {
|
||||||
|
const { unmount, qc } = mount({
|
||||||
|
details: { A: item("A") },
|
||||||
|
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||||
|
const refreshed = vi.spyOn(qc, "invalidateQueries");
|
||||||
|
unmount();
|
||||||
|
const keys = refreshed.mock.calls.map(([f]) => f?.queryKey);
|
||||||
|
expect(keys).toEqual(
|
||||||
|
expect.arrayContaining([qk.plexLibrary(), qk.plexShow(), qk.plexDiscover()]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("the subtitle poll and notice", () => {
|
describe("the subtitle poll and notice", () => {
|
||||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||||
const { status } = mount({
|
const { status } = mount({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ function Thumb({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
||||||
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
|
reveal on hover AND on keyboard focus (else a Tab-focused button is invisible), and
|
||||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||||
{onOpen && (
|
{onOpen && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
|||||||
import type { NEXT_PAGE } from "../lib/nextPage";
|
import type { NEXT_PAGE } from "../lib/nextPage";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
import { VideoMenu } from "./VideoMenu";
|
import { VideoMenu } from "./VideoMenu";
|
||||||
|
import type { VideoActionHandlers } from "./useVideoActions";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ export interface VirtualFeedProps {
|
|||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onResetState: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => void;
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||||||
|
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
||||||
|
channelFilter?: VideoActionHandlers["channelFilter"];
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
||||||
@@ -43,6 +46,7 @@ export default function VirtualFeed({
|
|||||||
onToggleSave,
|
onToggleSave,
|
||||||
onResetState,
|
onResetState,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
channelFilter,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetching,
|
isFetching,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
@@ -86,7 +90,10 @@ export default function VirtualFeed({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen }} />
|
<VideoMenu
|
||||||
|
menu={menu}
|
||||||
|
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import videoCard from "./VideoCard.tsx?raw";
|
import videoCard from "./VideoCard.tsx?raw";
|
||||||
|
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MEDIA_BAR,
|
MEDIA_BAR,
|
||||||
@@ -38,3 +40,28 @@ describe("a media picture", () => {
|
|||||||
expect(layer).not.toMatch(/pic:pb-\d/);
|
expect(layer).not.toMatch(/pic:pb-\d/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
// Back from a channel page hands the card its focus again, after a mouse trip: with `focus-within`
|
||||||
|
// its hover row stayed up under no pointer until a reload (owner). The chrome answers the pointer and
|
||||||
|
// KEYBOARD focus only (`:focus-visible` on the card or inside it); jsdom cannot evaluate either, so
|
||||||
|
// the classes are the contract, and the global `.reveal` rule says the same.
|
||||||
|
describe("a card's hover chrome", () => {
|
||||||
|
it.each([
|
||||||
|
["the hover row", HOVER_REVEAL],
|
||||||
|
["what yields to it", HOVER_YIELD],
|
||||||
|
["the Play layer", MEDIA_HOVER_LAYER],
|
||||||
|
["the Continue + Restart layer", MEDIA_RESUME_LAYER],
|
||||||
|
])("shows on keyboard focus, never on a focus handed back: %s", (_, cls) => {
|
||||||
|
const parts = cls.split(" ");
|
||||||
|
expect(parts.some((c) => c.startsWith("group-focus-within:"))).toBe(false);
|
||||||
|
expect(parts.some((c) => c.startsWith("group-focus-visible:"))).toBe(true);
|
||||||
|
expect(parts.some((c) => c.startsWith("group-has-focus-visible:"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the same in the global .reveal rule", () => {
|
||||||
|
expect(indexCss).not.toMatch(/\.group:focus-within \.reveal/);
|
||||||
|
expect(indexCss).toMatch(/\.group:has\(:focus-visible\) \.reveal/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { VideoDetail } from "../lib/api";
|
|||||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { StateMessage } from "./ui/QueryState";
|
import { StateMessage } from "./ui/QueryState";
|
||||||
|
import { ClampedText } from "./ui/ClampedText";
|
||||||
import { renderDescription } from "./descriptionLinks";
|
import { renderDescription } from "./descriptionLinks";
|
||||||
|
|
||||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||||
@@ -109,7 +110,8 @@ export function PlayerTransportControls({
|
|||||||
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
||||||
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
||||||
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
|
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
|
||||||
* `ref` / `style` / `className` dress the text's box only. */
|
* `ref` / `style` / `className` dress the text's box only. `clamp` cuts it to three lines with a
|
||||||
|
* More / Less (the panel, where the description sits under the actions and is rarely read whole). */
|
||||||
export function PlayerDescription({
|
export function PlayerDescription({
|
||||||
detail,
|
detail,
|
||||||
currentId,
|
currentId,
|
||||||
@@ -118,6 +120,7 @@ export function PlayerDescription({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
ref,
|
ref,
|
||||||
|
clamp = false,
|
||||||
}: {
|
}: {
|
||||||
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
||||||
currentId: string;
|
currentId: string;
|
||||||
@@ -126,6 +129,7 @@ export function PlayerDescription({
|
|||||||
className: string;
|
className: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
clamp?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
||||||
@@ -137,6 +141,18 @@ export function PlayerDescription({
|
|||||||
);
|
);
|
||||||
const text = detail.data?.description;
|
const text = detail.data?.description;
|
||||||
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
||||||
|
if (clamp)
|
||||||
|
return (
|
||||||
|
<ClampedText
|
||||||
|
text={text}
|
||||||
|
lines={3}
|
||||||
|
moreLabel={t("player.descriptionMore")}
|
||||||
|
lessLabel={t("player.descriptionLess")}
|
||||||
|
textClassName={className}
|
||||||
|
>
|
||||||
|
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||||
|
</ClampedText>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={style} className={className}>
|
<div ref={ref} style={style} className={className}>
|
||||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||||
// key-passthrough, so the asserted names are the i18n keys.
|
// key-passthrough, so the asserted names are the i18n keys.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
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")>();
|
||||||
@@ -21,7 +23,15 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||||
import { iconButtonClass } from "./ui/buttonStyles";
|
import { iconButtonClass } from "./ui/buttonStyles";
|
||||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const movie = (status?: string): PlexCard => ({
|
const movie = (status?: string): PlexCard => ({
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -169,14 +179,70 @@ describe("PlexPosterCard with a next episode", () => {
|
|||||||
expect(onInfo).toHaveBeenCalled();
|
expect(onInfo).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
|
||||||
|
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
|
||||||
|
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onPage = vi.fn();
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
|
||||||
);
|
);
|
||||||
expect(playGlyph(container)).toBeNull();
|
expect(playGlyph(container)).not.toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
fireEvent.click(screen.getByRole("button", { name: "Show" }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||||
|
expect(onPage).toHaveBeenCalledWith(show);
|
||||||
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("continues a started show and starts its episode over, as a started movie does", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onRestart = vi.fn();
|
||||||
|
render(
|
||||||
|
<PlexPosterCard
|
||||||
|
card={{ ...show, status: "in_progress", resumable: true }}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onRestart={onRestart}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
|
||||||
|
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
|
||||||
|
// Its next episode is untouched: Continue and Restart would both play it from its start.
|
||||||
|
render(
|
||||||
|
<PlexPosterCard
|
||||||
|
card={{ ...show, status: "in_progress", resumable: false }}
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onRestart={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the episode it would play once its preview is up, and not asked before", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const ask = vi
|
||||||
|
.spyOn(api, "plexShowNext")
|
||||||
|
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
|
||||||
|
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
|
||||||
|
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
});
|
||||||
|
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
expect(ask).toHaveBeenCalledWith("sh", "en");
|
||||||
|
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
|
||||||
|
ask.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
||||||
@@ -313,7 +379,7 @@ describe("Plex cards in a keyboard-walkable list", () => {
|
|||||||
describe("a Plex card's title stays selectable", () => {
|
describe("a Plex card's title stays selectable", () => {
|
||||||
it("opens from its caption, but not on the click that ends selecting the title", () => {
|
it("opens from its caption, but not on the click that ends selecting the title", () => {
|
||||||
const onOpen = vi.fn();
|
const onOpen = vi.fn();
|
||||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
|
||||||
const title = screen.getByText("M");
|
const title = screen.getByText("M");
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(title);
|
range.selectNodeContents(title);
|
||||||
@@ -327,10 +393,47 @@ describe("a Plex card's title stays selectable", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The picture plays; the title, lit like a link, opens the page of what it names (owner).
|
||||||
|
describe("a Plex card's title is a link to its page", () => {
|
||||||
|
it("a movie's title opens its page and never plays it", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onPage = vi.fn();
|
||||||
|
render(<PlexPosterCard card={movie()} onOpen={onOpen} onPage={onPage} />);
|
||||||
|
fireEvent.click(screen.getByText("M"), { detail: 1 });
|
||||||
|
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "1", type: "movie" }));
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an episode standing as its show opens the show", () => {
|
||||||
|
const onPage = vi.fn();
|
||||||
|
const ep: PlexCard = { ...episode(), show_id: "s9", show_title: "The Show" };
|
||||||
|
render(<PlexPosterCard card={ep} onOpen={vi.fn()} onPage={onPage} />);
|
||||||
|
fireEvent.click(screen.getByText("The Show"), { detail: 1 });
|
||||||
|
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "s9", type: "show" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an episode card's title opens the episode's page, its picture plays", () => {
|
||||||
|
const onPlay = vi.fn();
|
||||||
|
const onPage = vi.fn();
|
||||||
|
render(<EpisodeCard ep={episode()} onPlay={onPlay} onPage={onPage} />);
|
||||||
|
fireEvent.click(screen.getByText(episode().title), { detail: 1 });
|
||||||
|
expect(onPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPlay).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a page the title is plain text: a click does nothing", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
||||||
|
fireEvent.click(screen.getByText("M"), { detail: 1 });
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("M").className).not.toContain("cursor-pointer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("a Plex card's caption and a drag", () => {
|
describe("a Plex card's caption and a drag", () => {
|
||||||
it("does not open on a press dragged past the slop", () => {
|
it("does not open on a press dragged past the slop", () => {
|
||||||
const onOpen = vi.fn();
|
const onOpen = vi.fn();
|
||||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
|
||||||
const title = screen.getByText("M");
|
const title = screen.getByText("M");
|
||||||
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
|
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
|
||||||
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
|
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
|
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
|
||||||
// so an assertion can see which words went into the line.
|
// so an assertion can see which words went into the line.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
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")>();
|
||||||
@@ -17,6 +19,14 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
||||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const watchedEpisode: PlexCard = {
|
const watchedEpisode: PlexCard = {
|
||||||
|
|||||||
@@ -4,11 +4,21 @@
|
|||||||
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
||||||
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
import { render as rtlRender, screen, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
import type { PlexCard } from "../lib/api";
|
import type { PlexCard } from "../lib/api";
|
||||||
|
|
||||||
|
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||||
|
const render = (ui: ReactElement) =>
|
||||||
|
rtlRender(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
setLanguage("en");
|
setLanguage("en");
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { type ReactNode, useRef } from "react";
|
import { type ReactNode, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
import { formatDecimal, formatRuntime } from "../lib/format";
|
import { formatDecimal, formatRuntime } from "../lib/format";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
import { episodeLine, episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||||
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
||||||
@@ -30,8 +34,9 @@ import { useMediaPreview } from "./ui/useMediaPreview";
|
|||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { cardImage } from "../lib/plexImage";
|
import { cardImage } from "../lib/plexImage";
|
||||||
|
|
||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
|
||||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
|
||||||
|
// browse grid, the person page, and the series show/season views.
|
||||||
//
|
//
|
||||||
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
|
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
|
||||||
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
||||||
@@ -74,7 +79,7 @@ function HoverBar({
|
|||||||
onToggleWatched?: () => void;
|
onToggleWatched?: () => void;
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
|
/** A show's own page: its picture plays an episode, so the page has a button of its own. */
|
||||||
onInfo?: () => void;
|
onInfo?: () => void;
|
||||||
more: More;
|
more: More;
|
||||||
}) {
|
}) {
|
||||||
@@ -139,9 +144,11 @@ export function PlexHoverBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Plex card's title: it opens the card like its picture does, except for the click that ends
|
/** A Plex card's title: a link to the page of what it names (a movie's or an episode's info page, a
|
||||||
* selecting its words, or a press dragged past the slop (DESIGN "Text stays selectable"); a div's
|
* show's page), while the picture plays it — a title lit like a link that started playback surprised
|
||||||
* text selects on its own, so only the click needs the guard. */
|
* (owner). Not for the click that ends selecting its words, or a press dragged past the slop (DESIGN
|
||||||
|
* "Text stays selectable"); a div's text selects on its own, so only the click needs the guard.
|
||||||
|
* Without a page to open it is plain text. */
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
onClick,
|
onClick,
|
||||||
shelf = false,
|
shelf = false,
|
||||||
@@ -267,10 +274,14 @@ export function PlexPosterCard({
|
|||||||
next,
|
next,
|
||||||
onInfo,
|
onInfo,
|
||||||
onRestart,
|
onRestart,
|
||||||
|
onPage,
|
||||||
}: {
|
}: {
|
||||||
card: PlexCard;
|
card: PlexCard;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
/** Play the item (or the `next` episode) from its start, not its resume point (C5). */
|
/** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is
|
||||||
|
* handed the card the title NAMES — the show, for an episode standing as its show. */
|
||||||
|
onPage?: (target: PlexCard) => void;
|
||||||
|
/** Play the item (or the episode a show plays) from its start, not its resume point (C5). */
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
onToggleWatched?: (c: PlexCard) => void;
|
onToggleWatched?: (c: PlexCard) => void;
|
||||||
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
||||||
@@ -290,11 +301,12 @@ export function PlexPosterCard({
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
||||||
shelf?: boolean;
|
shelf?: boolean;
|
||||||
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
|
/** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
|
||||||
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
|
* add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
|
||||||
* names it, and an ⓘ (`onInfo`) opens the show. */
|
* asks the server which episode (`/show/{id}/next`) — and the preview names that episode once the
|
||||||
|
* pointer has rested on the card. */
|
||||||
next?: PlexCard | null;
|
next?: PlexCard | null;
|
||||||
/** The show's page, beside a `next` episode. */
|
/** The show's page, on the ⓘ (`onPage` where a surface has no other). */
|
||||||
onInfo?: () => void;
|
onInfo?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -306,9 +318,24 @@ export function PlexPosterCard({
|
|||||||
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
|
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
|
||||||
const landscape = wide || (card.type === "episode" && !asShow);
|
const landscape = wide || (card.type === "episode" && !asShow);
|
||||||
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
|
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
|
||||||
const ep = card.type === "show" ? (next ?? null) : null;
|
const titled: PlexCard = asShow
|
||||||
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
|
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
|
||||||
const isPlayable = card.type !== "show" || !!ep;
|
: card;
|
||||||
|
const isShow = card.type === "show";
|
||||||
|
const ep = isShow ? (next ?? null) : null;
|
||||||
|
// Every picture plays: a show its next episode. The hover toggles act on a leaf, so a show offers
|
||||||
|
// them only with its episode in hand.
|
||||||
|
const isPlayable = !isShow || !!ep;
|
||||||
|
const plang = plexLang(lang);
|
||||||
|
// Which episode a show would play, for the preview: asked once the pointer rests on the card, and
|
||||||
|
// fresh every time (staleTime 0), since a watch anywhere moves it.
|
||||||
|
const upNextQ = useQuery({
|
||||||
|
queryKey: qk.plexShowNext(card.id, plang),
|
||||||
|
queryFn: () => api.plexShowNext(card.id, plang),
|
||||||
|
enabled: isShow && !ep && preview.open,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
const upNext = ep ?? upNextQ.data?.next ?? null;
|
||||||
// What the hover row acts on: the episode a show would play, else the card itself.
|
// What the hover row acts on: the episode a show would play, else the card itself.
|
||||||
const acted = ep ?? card;
|
const acted = ep ?? card;
|
||||||
const watched = card.status === "watched";
|
const watched = card.status === "watched";
|
||||||
@@ -320,6 +347,8 @@ export function PlexPosterCard({
|
|||||||
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||||
// The next episode by name: its numbers, or its title when Plex has none for it.
|
// The next episode by name: its numbers, or its title when Plex has none for it.
|
||||||
const nextName = ep && episodeName(ep);
|
const nextName = ep && episodeName(ep);
|
||||||
|
const started = plexStarted(ep ?? card);
|
||||||
|
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
|
||||||
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
||||||
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||||
const corner =
|
const corner =
|
||||||
@@ -354,10 +383,10 @@ export function PlexPosterCard({
|
|||||||
label={name}
|
label={name}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
plays={isPlayable}
|
plays
|
||||||
small={shelf && landscape}
|
small={shelf && landscape}
|
||||||
pct={pct}
|
pct={pct}
|
||||||
started={plexStarted(acted)}
|
started={started}
|
||||||
bar={
|
bar={
|
||||||
<HoverBar
|
<HoverBar
|
||||||
watched={actedWatched}
|
watched={actedWatched}
|
||||||
@@ -373,7 +402,7 @@ export function PlexPosterCard({
|
|||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
addLabel={t(plexAddLabelKey(acted))}
|
addLabel={t(plexAddLabelKey(acted))}
|
||||||
onAdd={add}
|
onAdd={add}
|
||||||
onInfo={ep ? onInfo : undefined}
|
onInfo={showPage}
|
||||||
more={m}
|
more={m}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -385,7 +414,7 @@ export function PlexPosterCard({
|
|||||||
</PlexPicture>
|
</PlexPicture>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<CardTitle onClick={onOpen} shelf={shelf}>
|
<CardTitle onClick={onPage && (() => onPage(titled))} shelf={shelf}>
|
||||||
{asShow ? card.show_title : card.title}
|
{asShow ? card.show_title : card.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
}
|
}
|
||||||
@@ -423,6 +452,7 @@ export function PlexPosterCard({
|
|||||||
{rating}
|
{rating}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
|
||||||
summary={card.summary}
|
summary={card.summary}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -628,12 +658,18 @@ export function CastRow({
|
|||||||
export function RelatedStrip({
|
export function RelatedStrip({
|
||||||
related,
|
related,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onPlay,
|
||||||
|
onRestart,
|
||||||
menuProps,
|
menuProps,
|
||||||
menuButtonProps,
|
menuButtonProps,
|
||||||
openId = null,
|
openId = null,
|
||||||
}: {
|
}: {
|
||||||
related: PlexCard[];
|
related: PlexCard[];
|
||||||
|
/** A related show's page (its title and its ⓘ). */
|
||||||
onOpen: (c: PlexCard) => void;
|
onOpen: (c: PlexCard) => void;
|
||||||
|
/** Its picture: the episode it would play, as on every show card. */
|
||||||
|
onPlay: (c: PlexCard) => void;
|
||||||
|
onRestart: (c: PlexCard) => void;
|
||||||
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
|
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
|
||||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||||
@@ -654,7 +690,9 @@ export function RelatedStrip({
|
|||||||
card={r}
|
card={r}
|
||||||
shelf
|
shelf
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpen(r)}
|
onOpen={() => onPlay(r)}
|
||||||
|
onRestart={() => onRestart(r)}
|
||||||
|
onPage={onOpen}
|
||||||
menuProps={menuProps}
|
menuProps={menuProps}
|
||||||
menuButtonProps={menuButtonProps}
|
menuButtonProps={menuButtonProps}
|
||||||
menuOpen={openId === r.id}
|
menuOpen={openId === r.id}
|
||||||
@@ -677,9 +715,12 @@ export function EpisodeCard({
|
|||||||
menuButtonProps,
|
menuButtonProps,
|
||||||
menuOpen = false,
|
menuOpen = false,
|
||||||
navIndex,
|
navIndex,
|
||||||
|
onPage,
|
||||||
}: {
|
}: {
|
||||||
ep: PlexCard;
|
ep: PlexCard;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
|
/** The episode's info page: the title's link (the picture plays). */
|
||||||
|
onPage?: () => void;
|
||||||
/** Play the episode from its start, not its resume point (C5). */
|
/** Play the episode from its start, not its resume point (C5). */
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
@@ -732,7 +773,7 @@ export function EpisodeCard({
|
|||||||
</PlexPicture>
|
</PlexPicture>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<CardTitle onClick={onPlay}>
|
<CardTitle onClick={onPage}>
|
||||||
{!withShowTitle && ep.episode_number != null && (
|
{!withShowTitle && ep.episode_number != null && (
|
||||||
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
|
|||||||
onPlay={() => {}}
|
onPlay={() => {}}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -108,7 +108,15 @@ const ALL = ["e11", "e12", "e21", "e22"];
|
|||||||
describe("the restart button on a show and a season", () => {
|
describe("the restart button on a show and a season", () => {
|
||||||
it("a season's names the season and its first episode, and starts that episode at 0", async () => {
|
it("a season's names the season and its first episode, and starts that episode at 0", async () => {
|
||||||
const onPlay = vi.fn();
|
const onPlay = vi.fn();
|
||||||
wrap(<PlexSeasonView showId="show" seasonId="s2" onBack={() => {}} onPlay={onPlay} />);
|
wrap(
|
||||||
|
<PlexSeasonView
|
||||||
|
showId="show"
|
||||||
|
seasonId="s2"
|
||||||
|
onBack={() => {}}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onInfoCard={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
const btn = await screen.findByRole("button", { name: /plex\.series\.seasonFromStart/ });
|
const btn = await screen.findByRole("button", { name: /plex\.series\.seasonFromStart/ });
|
||||||
expect(btn.textContent).toContain("S2 · E1");
|
expect(btn.textContent).toContain("S2 · E1");
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
@@ -124,6 +132,7 @@ describe("the restart button on a show and a season", () => {
|
|||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
||||||
@@ -146,6 +155,7 @@ describe("the show page's summary", () => {
|
|||||||
onPlay={() => {}}
|
onPlay={() => {}}
|
||||||
onOpenSeason={() => {}}
|
onOpenSeason={() => {}}
|
||||||
onOpenShow={() => {}}
|
onOpenShow={() => {}}
|
||||||
|
onPlayShow={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export function PlexInfoView({
|
|||||||
onPlayItem,
|
onPlayItem,
|
||||||
onFilter,
|
onFilter,
|
||||||
onOpenPerson,
|
onOpenPerson,
|
||||||
|
onOpenShow,
|
||||||
|
onOpenSeason,
|
||||||
|
onOpenInfo,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -85,6 +88,9 @@ export function PlexInfoView({
|
|||||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
onOpenPerson: (name: string) => void;
|
onOpenPerson: (name: string) => void;
|
||||||
|
onOpenShow: (showId: string) => void;
|
||||||
|
onOpenSeason: (showId: string, seasonId: string) => void;
|
||||||
|
onOpenInfo: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = plexLang(i18n.language);
|
const lang = plexLang(i18n.language);
|
||||||
@@ -113,6 +119,9 @@ export function PlexInfoView({
|
|||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
onOpenPerson={onOpenPerson}
|
onOpenPerson={onOpenPerson}
|
||||||
|
onOpenShow={onOpenShow}
|
||||||
|
onOpenSeason={onOpenSeason}
|
||||||
|
onOpenInfo={onOpenInfo}
|
||||||
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||||
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||||
/>
|
/>
|
||||||
@@ -238,6 +247,7 @@ export function PlexPersonView({
|
|||||||
navIndex={i}
|
navIndex={i}
|
||||||
card={c}
|
card={c}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onPage={onInfoCard}
|
||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
{...cardMenu(c)}
|
{...cardMenu(c)}
|
||||||
/>
|
/>
|
||||||
@@ -372,6 +382,7 @@ export function PlexPersonView({
|
|||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onPage={onInfoCard}
|
||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
{...cardMenu(c)}
|
{...cardMenu(c)}
|
||||||
/>
|
/>
|
||||||
@@ -411,6 +422,7 @@ export function PlexShowView({
|
|||||||
onPlay,
|
onPlay,
|
||||||
onOpenSeason,
|
onOpenSeason,
|
||||||
onOpenShow,
|
onOpenShow,
|
||||||
|
onPlayShow,
|
||||||
onFilter,
|
onFilter,
|
||||||
onOpenPerson,
|
onOpenPerson,
|
||||||
}: {
|
}: {
|
||||||
@@ -419,6 +431,8 @@ export function PlexShowView({
|
|||||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||||
onOpenSeason: (seasonId: string) => void;
|
onOpenSeason: (seasonId: string) => void;
|
||||||
onOpenShow: (id: string) => void;
|
onOpenShow: (id: string) => void;
|
||||||
|
/** A related show's picture: the episode it would play, as on every show card. */
|
||||||
|
onPlayShow: (show: PlexCard, fromStart: boolean) => void;
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
onOpenPerson?: (name: string) => void;
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -435,7 +449,14 @@ export function PlexShowView({
|
|||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const relatedMenu = useContextMenu<PlexCard>();
|
const relatedMenu = useContextMenu<PlexCard>();
|
||||||
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
|
const related = usePlexCardActions(
|
||||||
|
{
|
||||||
|
onOpen: (c) => onPlayShow(c, false),
|
||||||
|
onRestart: (c) => onPlayShow(c, true),
|
||||||
|
onInfo: (c) => onOpenShow(c.id),
|
||||||
|
},
|
||||||
|
relatedMenu.open,
|
||||||
|
);
|
||||||
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
|
|
||||||
@@ -732,6 +753,8 @@ export function PlexShowView({
|
|||||||
<RelatedStrip
|
<RelatedStrip
|
||||||
related={d.related}
|
related={d.related}
|
||||||
onOpen={(c) => onOpenShow(c.id)}
|
onOpen={(c) => onOpenShow(c.id)}
|
||||||
|
onPlay={(c) => onPlayShow(c, false)}
|
||||||
|
onRestart={(c) => onPlayShow(c, true)}
|
||||||
menuProps={relatedMenu.triggerProps}
|
menuProps={relatedMenu.triggerProps}
|
||||||
menuButtonProps={relatedMenu.buttonProps}
|
menuButtonProps={relatedMenu.buttonProps}
|
||||||
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
||||||
@@ -765,11 +788,13 @@ export function PlexSeasonView({
|
|||||||
seasonId,
|
seasonId,
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
onInfoCard,
|
||||||
}: {
|
}: {
|
||||||
showId: string;
|
showId: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||||
|
onInfoCard: (card: PlexCard) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -822,6 +847,7 @@ export function PlexSeasonView({
|
|||||||
{
|
{
|
||||||
onOpen: (ep) => onPlay(ep.id, allKeys),
|
onOpen: (ep) => onPlay(ep.id, allKeys),
|
||||||
onRestart: (ep) => onPlay(ep.id, allKeys, true),
|
onRestart: (ep) => onPlay(ep.id, allKeys, true),
|
||||||
|
onInfo: onInfoCard,
|
||||||
onToggleWatched: (ep) => void markEpisode(ep),
|
onToggleWatched: (ep) => void markEpisode(ep),
|
||||||
},
|
},
|
||||||
epMenu.open,
|
epMenu.open,
|
||||||
@@ -918,6 +944,7 @@ export function PlexSeasonView({
|
|||||||
navIndex={i}
|
navIndex={i}
|
||||||
ep={ep}
|
ep={ep}
|
||||||
onPlay={() => onPlay(ep.id, allKeys)}
|
onPlay={() => onPlay(ep.id, allKeys)}
|
||||||
|
onPage={() => onInfoCard(ep)}
|
||||||
onRestart={() => onPlay(ep.id, allKeys, true)}
|
onRestart={() => onPlay(ep.id, allKeys, true)}
|
||||||
onAdd={() => episodes.add(ep)}
|
onAdd={() => episodes.add(ep)}
|
||||||
onToggleWatched={() => markEpisode(ep)}
|
onToggleWatched={() => markEpisode(ep)}
|
||||||
|
|||||||
@@ -45,6 +45,39 @@ describe("ClampedText", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens when focus reaches a link the cut hides, and not for one in sight", () => {
|
||||||
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||||
|
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<ClampedText text="t" lines={3} moreLabel="More" lessLabel="Less">
|
||||||
|
<a href="#a">seen</a>
|
||||||
|
<a href="#b">cut</a>
|
||||||
|
</ClampedText>,
|
||||||
|
);
|
||||||
|
const clamp = screen.getByText("seen").parentElement!;
|
||||||
|
vi.spyOn(clamp, "getBoundingClientRect").mockReturnValue(rect(100, 160));
|
||||||
|
vi.spyOn(screen.getByText("seen"), "getBoundingClientRect").mockReturnValue(rect(100, 120));
|
||||||
|
act(() => screen.getByText("seen").focus());
|
||||||
|
expect(screen.getByText("More")).toBeTruthy();
|
||||||
|
// A Tab onto a link below the cut. The browser scrolls the clamped box to show it (measured in
|
||||||
|
// Chrome: 45 px), so on screen the link sits inside the box: only the text's own top tells.
|
||||||
|
clamp.scrollTop = 240;
|
||||||
|
vi.spyOn(screen.getByText("cut"), "getBoundingClientRect").mockReturnValue(rect(130, 150));
|
||||||
|
// Opened, the link drops back to its place in the text, as far as the box had scrolled: in a
|
||||||
|
// scrolling panel that is out of sight, so it is brought back into view.
|
||||||
|
const reveal = vi.fn();
|
||||||
|
screen.getByText("cut").scrollIntoView = reveal;
|
||||||
|
act(() => screen.getByText("cut").focus());
|
||||||
|
expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(reveal).toHaveBeenCalledWith({ block: "nearest" });
|
||||||
|
} finally {
|
||||||
|
tall.mockRestore();
|
||||||
|
box.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("starts another text collapsed: a reused view opening the next item", () => {
|
it("starts another text collapsed: a reused view opening the next item", () => {
|
||||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
// Static class names, so Tailwind sees them.
|
// Static class names, so Tailwind sees them.
|
||||||
@@ -8,19 +8,27 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con
|
|||||||
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
||||||
* resize: the window and the text-size slider both move where the cut falls. Another text starts
|
* resize: the window and the text-size slider both move where the cut falls. Another text starts
|
||||||
* collapsed: a view reused for the next item (a show page opening another show) must not open its
|
* collapsed: a view reused for the next item (a show page opening another show) must not open its
|
||||||
* text already expanded. */
|
* text already expanded. `children` draws the text in a richer form (a description's links and
|
||||||
|
* timestamps); `text` still names it, so another text is still another text. A link the cut hides
|
||||||
|
* stays in the tab order (it is still text), so reaching it opens the text: focus never sits on
|
||||||
|
* something out of sight. */
|
||||||
export function ClampedText({
|
export function ClampedText({
|
||||||
text,
|
text,
|
||||||
lines,
|
lines,
|
||||||
moreLabel,
|
moreLabel,
|
||||||
lessLabel,
|
lessLabel,
|
||||||
className,
|
className,
|
||||||
|
textClassName = "measure whitespace-pre-line text-sm leading-relaxed text-muted",
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
lines: keyof typeof CLAMP;
|
lines: keyof typeof CLAMP;
|
||||||
moreLabel: string;
|
moreLabel: string;
|
||||||
lessLabel: string;
|
lessLabel: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** The text box's own dress (the default: a summary's muted paragraph). */
|
||||||
|
textClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [clipped, setClipped] = useState(false);
|
const [clipped, setClipped] = useState(false);
|
||||||
@@ -29,7 +37,7 @@ export function ClampedText({
|
|||||||
setShown(text);
|
setShown(text);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}
|
}
|
||||||
const ref = useRef<HTMLParagraphElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || expanded) return;
|
if (!el || expanded) return;
|
||||||
@@ -39,14 +47,33 @@ export function ClampedText({
|
|||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [text, expanded]);
|
}, [text, expanded]);
|
||||||
|
// Measured from the text's top, not against the box on screen: focus scrolls the clamped box itself
|
||||||
|
// to bring the link into view (overflow:hidden still scrolls), so on screen it looked "in sight".
|
||||||
|
const revealing = useRef<Element | null>(null);
|
||||||
|
const revealFocused = (e: FocusEvent<HTMLDivElement>) => {
|
||||||
|
const box = e.currentTarget;
|
||||||
|
if (expanded || e.target === box) return;
|
||||||
|
const top = box.getBoundingClientRect().top - box.scrollTop;
|
||||||
|
if (e.target.getBoundingClientRect().bottom - top <= box.clientHeight + 1) return;
|
||||||
|
revealing.current = e.target;
|
||||||
|
setExpanded(true);
|
||||||
|
};
|
||||||
|
// That scroll was the box's own: opened, the link drops back to its place in the text, as far below
|
||||||
|
// as the box had scrolled, and out of a scrolling panel's sight. Brought back into view.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = revealing.current;
|
||||||
|
revealing.current = null;
|
||||||
|
if (expanded && el?.isConnected) el.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [expanded]);
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<p
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`}
|
className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}
|
||||||
|
onFocus={revealFocused}
|
||||||
>
|
>
|
||||||
{text}
|
{children ?? text}
|
||||||
</p>
|
</div>
|
||||||
{(clipped || expanded) && (
|
{(clipped || expanded) && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ export function MediaPreview({
|
|||||||
preview,
|
preview,
|
||||||
title,
|
title,
|
||||||
facts,
|
facts,
|
||||||
|
note,
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
preview: ReturnType<typeof useMediaPreview>;
|
preview: ReturnType<typeof useMediaPreview>;
|
||||||
title: string;
|
title: string;
|
||||||
/** One line under the title: year, length, score. */
|
/** One line under the title: year, length, score. */
|
||||||
facts?: ReactNode;
|
facts?: ReactNode;
|
||||||
|
/** A line of its own under the facts (a show's "Next: S6·E16 — Title"). */
|
||||||
|
note?: ReactNode;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +39,12 @@ export function MediaPreview({
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
||||||
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
||||||
{summary && <p className="mt-2 line-clamp-8 text-xs leading-relaxed text-fg">{summary}</p>}
|
{note && <p className="mt-1 text-xs font-medium text-fg">{note}</p>}
|
||||||
|
{summary && (
|
||||||
|
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
|||||||
glass: "font-medium glass-card glass-hover act:hover:text-accent",
|
glass: "font-medium glass-card glass-hover act:hover:text-accent",
|
||||||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||||||
overlay: "font-medium text-white/80 act:hover:text-white act:hover:bg-white/15",
|
overlay: "font-medium text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||||||
media: "font-medium bg-black/60 text-white act:hover:bg-black/75",
|
// On a picture a layer is lost in the picture's own light and dark: the hover is the accent edge
|
||||||
|
// (B3's edged-pill rule), a 2px ring over a darker body (owner pick B).
|
||||||
|
media:
|
||||||
|
"font-medium bg-black/60 text-white act:hover:bg-black/80 act:hover:ring-2 act:hover:ring-accent",
|
||||||
"danger-soft":
|
"danger-soft":
|
||||||
"font-medium border border-danger/60 text-danger state-layer act:hover:border-danger",
|
"font-medium border border-danger/60 text-danger state-layer act:hover:border-danger",
|
||||||
link: "font-semibold text-accent act:hover:underline",
|
link: "font-semibold text-accent act:hover:underline",
|
||||||
@@ -112,7 +115,7 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
|
|||||||
glass: "glass-card glass-hover act:hover:text-accent",
|
glass: "glass-card glass-hover act:hover:text-accent",
|
||||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||||
media: "bg-black/60 text-white act:hover:bg-black/75",
|
media: "bg-black/60 text-white act:hover:bg-black/80 act:hover:ring-2 act:hover:ring-accent",
|
||||||
overlay: "text-white/80 act:hover:text-white act:hover:bg-white/15",
|
overlay: "text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-r
|
|||||||
* (measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide.
|
* (measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide.
|
||||||
* Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */
|
* Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */
|
||||||
export const MEDIA_HOVER_LAYER =
|
export const MEDIA_HOVER_LAYER =
|
||||||
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)";
|
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-has-focus-visible:opacity-100 @max-[14rem]/pic:pb-(--bar-room)";
|
||||||
|
|
||||||
/** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the
|
/** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the
|
||||||
* picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */
|
* picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
"PlayerModal.tsx": [2, "previous / next arrows"],
|
|
||||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
// A card's hover chrome: shown when the card is hovered or holds keyboard focus on a mouse,
|
// A card's hover chrome: shown when the card is hovered or holds KEYBOARD focus on a mouse,
|
||||||
// removed on touch (`.reveal-hover`, index.css), where the card offers the same actions through its
|
// removed on touch (`.reveal-hover`, index.css), where the card offers the same actions through its
|
||||||
// "…" beside the title. One string, so the video card and the Plex cards reveal the same way.
|
// "…" beside the title. One string, so the video card and the Plex cards reveal the same way.
|
||||||
|
// Keyboard focus is `:focus-visible` on the card or inside it, not any focus: Back from a channel
|
||||||
|
// page hands the card its focus back, and with `:focus-within` the row stayed up under no pointer
|
||||||
|
// until a reload (owner).
|
||||||
export const HOVER_REVEAL =
|
export const HOVER_REVEAL =
|
||||||
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity";
|
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 group-has-focus-visible:opacity-100 transition-opacity";
|
||||||
|
|
||||||
/** What sits under that chrome at rest (a duration, a watched pill) steps aside while it shows. */
|
/** What sits under that chrome at rest (a duration, a watched pill) steps aside while it shows. */
|
||||||
export const HOVER_YIELD = "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0";
|
export const HOVER_YIELD =
|
||||||
|
"transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 group-has-focus-visible:opacity-0";
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function useMediaPreview(enabled = true) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
popover: pop,
|
popover: pop,
|
||||||
|
/** The preview is up: the pointer has rested on the card (what a card may fetch for it waits on). */
|
||||||
|
open: open && enabled,
|
||||||
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
||||||
* the root's own (its menu's right-click and long-press). */
|
* the root's own (its menu's right-click and long-press). */
|
||||||
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A show's card plays (the owner, 2026-10-09): the click asks the server which episode, every time —
|
||||||
|
// a preview answer from before a watch elsewhere must not decide it.
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const notified = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../lib/actionErrors", () => ({ notifyActionError: notified }));
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "hu" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { usePlayShow } from "./usePlayShow";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
notified.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "" };
|
||||||
|
const ep = (id: string): PlexCard => ({ id, type: "episode", title: id, thumb: "" });
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const play = vi.fn();
|
||||||
|
const openShow = vi.fn();
|
||||||
|
const { result, rerender, unmount } = renderHook(
|
||||||
|
({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
|
||||||
|
{
|
||||||
|
initialProps: { here: "grid" as unknown },
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { qc, play, openShow, playShow: result.current, rerender, unmount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers the test hands out one by one, in the order it chooses. */
|
||||||
|
function answers() {
|
||||||
|
const pending: ((v: { next: PlexCard | null }) => void)[] = [];
|
||||||
|
vi.spyOn(api, "plexShowNext").mockImplementation(() => new Promise((r) => void pending.push(r)));
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePlayShow", () => {
|
||||||
|
it("plays the episode the server names, from the start when asked to", async () => {
|
||||||
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e7") });
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, true));
|
||||||
|
expect(ask).toHaveBeenCalledWith("sh", "hu");
|
||||||
|
expect(play).toHaveBeenCalledWith("e7", true);
|
||||||
|
expect(openShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks again rather than play what the preview cached before a watch", async () => {
|
||||||
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e8") });
|
||||||
|
const { qc, play, playShow } = mount();
|
||||||
|
qc.setQueryData(qk.plexShowNext("sh", "hu"), { next: ep("e7") });
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(ask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(play).toHaveBeenCalledWith("e8", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the page of a show with nothing to play", async () => {
|
||||||
|
vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: null });
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(openShow).toHaveBeenCalledWith("sh");
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acts on the last click only: an earlier, slower answer is dropped", async () => {
|
||||||
|
const pending = answers();
|
||||||
|
const { play, playShow } = mount();
|
||||||
|
const first = playShow({ ...show, id: "a" }, false);
|
||||||
|
const second = playShow({ ...show, id: "b" }, false);
|
||||||
|
await act(async () => pending[1]!({ next: ep("b1") }));
|
||||||
|
await act(async () => pending[0]!({ next: ep("a1") }));
|
||||||
|
await act(() => Promise.all([first, second]));
|
||||||
|
expect(play.mock.calls).toEqual([["b1", false]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an answer that lands after the viewer has moved on, or left", async () => {
|
||||||
|
const pending = answers();
|
||||||
|
const { play, openShow, playShow, rerender, unmount } = mount();
|
||||||
|
const moved = playShow(show, false);
|
||||||
|
rerender({ here: "info" });
|
||||||
|
await act(async () => pending[0]!({ next: ep("e1") }));
|
||||||
|
await act(() => moved);
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
const left = playShow(show, false);
|
||||||
|
unmount();
|
||||||
|
await act(async () => pending[1]!({ next: null }));
|
||||||
|
await act(() => left);
|
||||||
|
expect(openShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the question fails, and opens nothing", async () => {
|
||||||
|
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||||
|
const { play, openShow, playShow } = mount();
|
||||||
|
await act(() => playShow(show, false));
|
||||||
|
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(openShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
|
import { useLatest } from "../lib/useLatest";
|
||||||
|
|
||||||
|
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||||
|
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||||
|
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
|
||||||
|
* (`openShow`) and says so; a failed question is a toast, and nothing opens.
|
||||||
|
*
|
||||||
|
* The answer acts only for the LAST click, and only where it was made (`here`, the caller's view): a
|
||||||
|
* slow answer to a click the viewer has since moved on from (another card, Back, another module)
|
||||||
|
* would otherwise pull them into a player they no longer asked for. */
|
||||||
|
export function usePlayShow(
|
||||||
|
play: (episodeId: string, fromStart: boolean) => void,
|
||||||
|
openShow: (showId: string) => void,
|
||||||
|
here: unknown,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const at = useLatest(here);
|
||||||
|
const asked = useRef(0);
|
||||||
|
// Unmounted, nothing of it may navigate any more.
|
||||||
|
useEffect(() => () => void ++asked.current, []);
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = plexLang(i18n.language);
|
||||||
|
// `undefined` when the question failed (said in the toast).
|
||||||
|
const next = (id: string) =>
|
||||||
|
qc
|
||||||
|
.fetchQuery({
|
||||||
|
queryKey: qk.plexShowNext(id, lang),
|
||||||
|
queryFn: () => api.plexShowNext(id, lang),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(r) => r.next,
|
||||||
|
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||||
|
);
|
||||||
|
return async (show: PlexCard, fromStart: boolean) => {
|
||||||
|
const ask = ++asked.current;
|
||||||
|
const from = at.current;
|
||||||
|
const ep = await next(show.id);
|
||||||
|
if (ask !== asked.current || at.current !== from || ep === undefined) return;
|
||||||
|
if (ep) play(ep.id, fromStart);
|
||||||
|
else openShow(show.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -14,8 +17,10 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
|||||||
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
|
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
|
||||||
|
|
||||||
export interface PlexActionHandlers {
|
export interface PlexActionHandlers {
|
||||||
|
/** Play it: a show, the episode it would play (the surface resolves it). */
|
||||||
onOpen: (c: PlexCard) => void;
|
onOpen: (c: PlexCard) => void;
|
||||||
/** The media-info page. Omitted where a surface has none (the episode lists). */
|
/** The media-info page, or a show's own page. Omitted where a surface has none (the episode
|
||||||
|
* lists). */
|
||||||
onInfo?: (c: PlexCard) => void;
|
onInfo?: (c: PlexCard) => void;
|
||||||
onToggleWatched?: (c: PlexCard) => void;
|
onToggleWatched?: (c: PlexCard) => void;
|
||||||
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
||||||
@@ -29,13 +34,18 @@ const icon = "w-4 h-4";
|
|||||||
export const plexAddLabelKey = (c: PlexCard): string =>
|
export const plexAddLabelKey = (c: PlexCard): string =>
|
||||||
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
|
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
|
||||||
|
|
||||||
/** A show opens its drill-down; a movie or an episode plays. */
|
/** A leaf: a movie or an episode, what the watched toggle, the playlist add and a resume point
|
||||||
|
* belong to. A show plays too, through the episode it resolves to. */
|
||||||
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
||||||
|
|
||||||
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
||||||
* stored at 0, so it never counts. */
|
* stored at 0, so it never counts. A show has one when an episode does (`resumable`, the episode its
|
||||||
|
* click plays): part watched with its next episode untouched, Continue and Restart would be the same
|
||||||
|
* thing, so it is not "started" here — its in-progress badge says how far it has got. */
|
||||||
export const plexStarted = (c: PlexCard): boolean =>
|
export const plexStarted = (c: PlexCard): boolean =>
|
||||||
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
|
isPlexPlayable(c)
|
||||||
|
? c.status !== "watched" && (c.position_seconds ?? 0) > 0
|
||||||
|
: c.resumable === true;
|
||||||
|
|
||||||
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
|
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
|
||||||
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
|
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
|
||||||
@@ -46,18 +56,31 @@ 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);
|
||||||
|
const isShow = c.type === "show";
|
||||||
const watched = c.status === "watched";
|
const watched = c.status === "watched";
|
||||||
const started = plexStarted(c);
|
const started = plexStarted(c);
|
||||||
return [
|
return [
|
||||||
@@ -65,8 +88,8 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "play",
|
id: "play",
|
||||||
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
|
label: started ? t("card.continue") : t("plex.play"),
|
||||||
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
|
icon: <Play className={icon} />,
|
||||||
onSelect: () => onOpen(c),
|
onSelect: () => onOpen(c),
|
||||||
},
|
},
|
||||||
// The same words and glyph as the video card's menu (useVideoActions).
|
// The same words and glyph as the video card's menu (useVideoActions).
|
||||||
@@ -104,23 +127,42 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(playable && onInfo
|
// 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 }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(onInfo
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "details",
|
id: "details",
|
||||||
label: t("plex.info.title"),
|
label: isShow ? t("plex.openShow") : t("plex.info.title"),
|
||||||
icon: <Info className={icon} />,
|
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
|
||||||
separatorBefore: true,
|
separatorBefore: true,
|
||||||
onSelect: () => onInfo(c),
|
onSelect: () => onInfo(c),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })),
|
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
||||||
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,3 +55,52 @@ describe("the video menu's download row", () => {
|
|||||||
expect(none().note).toBeUndefined();
|
expect(none().note).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The feed's channel filter from a card (owner): under "Open the channel", a row that adds the card's
|
||||||
|
// channel to the filter, or takes it out again when it is already in.
|
||||||
|
describe("the video menu's channel-filter row", () => {
|
||||||
|
const menu = (ids: string[] | null) => {
|
||||||
|
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
|
||||||
|
const set = vi.fn();
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useVideoActions(
|
||||||
|
{
|
||||||
|
onState: vi.fn(),
|
||||||
|
onToggleSave: vi.fn(),
|
||||||
|
channelFilter: ids ? { ids, set } : undefined,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
const items = result.current.items({ ...video, channel_id: "UCa" }, null);
|
||||||
|
return { items, set };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("adds the channel to a filter that names others, right under Open the channel", () => {
|
||||||
|
const { items, set } = menu(["UCz"]);
|
||||||
|
const at = items.findIndex((i) => i.id === "channel");
|
||||||
|
const row = items[at + 1]!;
|
||||||
|
expect(row.id).toBe("channel-filter");
|
||||||
|
expect(row.label).toBe("card.menu.channelFilterAdd");
|
||||||
|
row.onSelect?.();
|
||||||
|
expect(set).toHaveBeenCalledWith(["UCz", "UCa"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes it out again when it is in", () => {
|
||||||
|
const { items, set } = menu(["UCz", "UCa"]);
|
||||||
|
const row = items.find((i) => i.id === "channel-filter")!;
|
||||||
|
expect(row.label).toBe("card.menu.channelFilterRemove");
|
||||||
|
row.onSelect?.();
|
||||||
|
expect(set).toHaveBeenCalledWith(["UCz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is absent where the host gives no filter (a channel page, Home, a live search)", () => {
|
||||||
|
expect(menu(null).items.some((i) => i.id === "channel-filter")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Filter,
|
||||||
|
FilterX,
|
||||||
Info,
|
Info,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Play,
|
Play,
|
||||||
@@ -39,6 +41,9 @@ export interface VideoActionHandlers {
|
|||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
|
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
||||||
|
* page (the view is that channel already), in a live YouTube search and on Home. */
|
||||||
|
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = "w-4 h-4";
|
const icon = "w-4 h-4";
|
||||||
@@ -65,7 +70,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
anchor: Element | null,
|
anchor: Element | null,
|
||||||
{ pin: withPin = true }: { pin?: boolean } = {},
|
{ pin: withPin = true }: { pin?: boolean } = {},
|
||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onState, onToggleSave, onResetState, onOpen } = handlers;
|
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
|
||||||
const watched = v.status === "watched";
|
const watched = v.status === "watched";
|
||||||
const hidden = v.status === "hidden";
|
const hidden = v.status === "hidden";
|
||||||
const inProgress = v.position_seconds > 0 && !watched;
|
const inProgress = v.position_seconds > 0 && !watched;
|
||||||
@@ -112,6 +117,26 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
icon: <Tv className={icon} />,
|
icon: <Tv className={icon} />,
|
||||||
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
|
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
|
||||||
},
|
},
|
||||||
|
// The channel's videos in this feed, or the channel out of the filter again: channels OR
|
||||||
|
// together, so adding one widens a filter that already names others (as "Show in feed" does).
|
||||||
|
...(channelFilter
|
||||||
|
? [
|
||||||
|
channelFilter.ids.includes(v.channel_id)
|
||||||
|
? {
|
||||||
|
id: "channel-filter",
|
||||||
|
label: t("card.menu.channelFilterRemove"),
|
||||||
|
icon: <FilterX className={icon} />,
|
||||||
|
onSelect: () =>
|
||||||
|
channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "channel-filter",
|
||||||
|
label: t("card.menu.channelFilterAdd"),
|
||||||
|
icon: <Filter className={icon} />,
|
||||||
|
onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "watched",
|
id: "watched",
|
||||||
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.104.0",
|
||||||
|
"date": "2026-10-10",
|
||||||
|
"new": [
|
||||||
|
"YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand.",
|
||||||
|
"Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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,27 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.104.0",
|
||||||
|
"date": "2026-10-10",
|
||||||
|
"new": [
|
||||||
|
"YouTube: a kártya menüje a csatornájára szűri a hírfolyamot, majd vissza, a lejátszó pedig több helyet ad a videónak — az oldalpanel igény szerint becsukható.",
|
||||||
|
"Plex: a kártya képe lejátssza a címet, a címe megnyitja az oldalát; a sorozatkártya azt a részt indítja, amelyet folytatna vagy kezdene, egy epizódról pedig a sorozatára lehet lépni."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Plex: a HTML-ként írt leírások tiszta szövegként jelennek meg, a Meglepetés-húzás pedig marad, amíg újra nem keversz. A lejátszóban megnézettnek jelölt videó kimarad a Nem nézett hírfolyamból. Továbbá kisebb billentyűzet- és fókuszjavítások."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"unmarkWatched": "Mark unwatched",
|
"unmarkWatched": "Mark unwatched",
|
||||||
"unsave": "Remove from saved",
|
"unsave": "Remove from saved",
|
||||||
"more": "More actions",
|
"more": "More actions",
|
||||||
"openChannel": "Open the channel"
|
"openChannel": "Open the channel",
|
||||||
|
"channelFilterAdd": "Filter by this channel",
|
||||||
|
"channelFilterRemove": "Remove the channel from the filter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"noDescription": "No description.",
|
"noDescription": "No description.",
|
||||||
"descriptionFailed": "Couldn't load the description",
|
"descriptionFailed": "Couldn't load the description",
|
||||||
|
"descriptionMore": "More",
|
||||||
|
"descriptionLess": "Less",
|
||||||
|
"collapsePanel": "Hide the details",
|
||||||
|
"expandPanel": "Show the details",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"views": "{{formattedCount}} views",
|
"views": "{{formattedCount}} views",
|
||||||
"stream": "stream",
|
"stream": "stream",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "Season {{n}}",
|
"seasonTitle": "Season {{n}}",
|
||||||
"specials": "Specials",
|
"specials": "Specials",
|
||||||
|
"playShowFailed": "Couldn't find the episode to play — please try again",
|
||||||
"watchedFailed": "Couldn't update the watched state — please try again",
|
"watchedFailed": "Couldn't update the watched state — please try again",
|
||||||
"seasons_one": "{{count}} season",
|
"seasons_one": "{{count}} season",
|
||||||
"seasons_other": "{{count}} seasons",
|
"seasons_other": "{{count}} seasons",
|
||||||
@@ -384,5 +385,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"unmarkWatched": "Megnézés visszavonása",
|
"unmarkWatched": "Megnézés visszavonása",
|
||||||
"unsave": "Eltávolítás a mentettek közül",
|
"unsave": "Eltávolítás a mentettek közül",
|
||||||
"more": "További műveletek",
|
"more": "További műveletek",
|
||||||
"openChannel": "Csatorna megnyitása"
|
"openChannel": "Csatorna megnyitása",
|
||||||
|
"channelFilterAdd": "Szűrés erre a csatornára",
|
||||||
|
"channelFilterRemove": "A csatorna ki a szűrőből"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
"description": "Leírás",
|
"description": "Leírás",
|
||||||
"noDescription": "Nincs leírás.",
|
"noDescription": "Nincs leírás.",
|
||||||
"descriptionFailed": "Nem sikerült betölteni a leírást",
|
"descriptionFailed": "Nem sikerült betölteni a leírást",
|
||||||
|
"descriptionMore": "Több",
|
||||||
|
"descriptionLess": "Kevesebb",
|
||||||
|
"collapsePanel": "Részletek elrejtése",
|
||||||
|
"expandPanel": "Részletek megjelenítése",
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
"views": "{{formattedCount}} megtekintés",
|
"views": "{{formattedCount}} megtekintés",
|
||||||
"stream": "élő adás",
|
"stream": "élő adás",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "{{n}}. évad",
|
"seasonTitle": "{{n}}. évad",
|
||||||
"specials": "Különkiadások",
|
"specials": "Különkiadások",
|
||||||
|
"playShowFailed": "Nem sikerült megtalálni a lejátszandó epizódot — próbáld újra",
|
||||||
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
|
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
|
||||||
"seasons_one": "{{count}} évad",
|
"seasons_one": "{{count}} évad",
|
||||||
"seasons_other": "{{count}} évad",
|
"seasons_other": "{{count}} évad",
|
||||||
@@ -384,5 +385,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -787,7 +787,8 @@ body {
|
|||||||
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
|
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
|
||||||
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
|
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
|
||||||
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
|
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
|
||||||
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
|
or keyboard-focused (`:focus-visible` on it or inside it — not any focus: a card handed its focus
|
||||||
|
back after a mouse trip kept its row up); a touch device keeps the default opacity:1 and the
|
||||||
control is always shown. Requires a `.group` ancestor (Tailwind's group marker). */
|
control is always shown. Requires a `.group` ancestor (Tailwind's group marker). */
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.reveal {
|
.reveal {
|
||||||
@@ -795,7 +796,8 @@ body {
|
|||||||
transition: opacity 150ms ease;
|
transition: opacity 150ms ease;
|
||||||
}
|
}
|
||||||
.group:hover .reveal,
|
.group:hover .reveal,
|
||||||
.group:focus-within .reveal,
|
.group:focus-visible .reveal,
|
||||||
|
.group:has(:focus-visible) .reveal,
|
||||||
.reveal:focus-visible {
|
.reveal:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -810,7 +812,7 @@ body {
|
|||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
||||||
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
||||||
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
|
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-visible:…`
|
||||||
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
||||||
already reachable on touch another way — a card's hover row (its "…" beside the title opens the
|
already reachable on touch another way — a card's hover row (its "…" beside the title opens the
|
||||||
same actions: `ui/reveal.ts`), VideoCard's play overlay (tapping the card plays/resumes)
|
same actions: `ui/reveal.ts`), VideoCard's play overlay (tapping the card plays/resumes)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface PlexCard {
|
|||||||
status?: string; // new | watched | hidden
|
status?: string; // new | watched | hidden
|
||||||
position_seconds?: number;
|
position_seconds?: number;
|
||||||
season_count?: number | null; // show
|
season_count?: number | null; // show
|
||||||
|
// show: an unwatched episode holds a resume point (Continue resumes it, Restart replays it)
|
||||||
|
resumable?: boolean;
|
||||||
season_number?: number | null; // episode
|
season_number?: number | null; // episode
|
||||||
episode_number?: number | null; // episode
|
episode_number?: number | null; // episode
|
||||||
show_title?: string | null; // episode (in a mixed playlist)
|
show_title?: string | null; // episode (in a mixed playlist)
|
||||||
@@ -180,6 +182,8 @@ export interface PlexItemDetail {
|
|||||||
status: string;
|
status: string;
|
||||||
position_seconds: number;
|
position_seconds: number;
|
||||||
show_title?: string | null;
|
show_title?: string | null;
|
||||||
|
show_id?: string | null; // an episode's show and season rating keys (its page links up to them)
|
||||||
|
season_id?: string | null;
|
||||||
season_number?: number | null;
|
season_number?: number | null;
|
||||||
episode_number?: number | null;
|
episode_number?: number | null;
|
||||||
prev_id?: string | null;
|
prev_id?: string | null;
|
||||||
@@ -508,6 +512,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> => {
|
||||||
@@ -618,22 +628,30 @@ export const plexApi = {
|
|||||||
if (filters) appendPlexFilters(u, filters);
|
if (filters) appendPlexFilters(u, filters);
|
||||||
return req(`/api/plex/discover?${u.toString()}`);
|
return req(`/api/plex/discover?${u.toString()}`);
|
||||||
},
|
},
|
||||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
|
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻), narrowed by the
|
||||||
// the active filters like the other rails.
|
// active filters like the other rails. `exclude` is the previous draw: dealt last, not first. `keep`
|
||||||
|
// asks for no draw at all: those titles again, in that order, with their watch state as it is now.
|
||||||
plexSurprise: (
|
plexSurprise: (
|
||||||
scope: string,
|
scope: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
filters?: PlexFilters,
|
filters?: PlexFilters,
|
||||||
lang?: string,
|
lang?: string,
|
||||||
|
{ exclude = [], keep = [] }: { exclude?: string[]; keep?: string[] } = {},
|
||||||
): Promise<{ scope: string; items: PlexCard[] }> => {
|
): Promise<{ scope: string; items: PlexCard[] }> => {
|
||||||
const u = new URLSearchParams({ scope });
|
const u = new URLSearchParams({ scope });
|
||||||
if (limit) u.set("limit", String(limit));
|
if (limit) u.set("limit", String(limit));
|
||||||
|
for (const id of exclude) u.append("exclude", id);
|
||||||
|
for (const id of keep) u.append("keep", id);
|
||||||
if (lang === "hu") u.set("lang", "hu");
|
if (lang === "hu") u.set("lang", "hu");
|
||||||
if (filters) appendPlexFilters(u, filters);
|
if (filters) appendPlexFilters(u, filters);
|
||||||
return req(`/api/plex/surprise?${u.toString()}`);
|
return req(`/api/plex/surprise?${u.toString()}`);
|
||||||
},
|
},
|
||||||
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
|
// The episode a show's card plays: in progress, else the first unwatched, else the first (null:
|
||||||
|
// no episodes). Asked per show, as a card is hovered or played (the route says why).
|
||||||
|
plexShowNext: (id: string, lang?: string): Promise<{ next: PlexCard | null }> =>
|
||||||
|
req(`/api/plex/show/${encodeURIComponent(id)}/next${lang === "hu" ? "?lang=hu" : ""}`),
|
||||||
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||||
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user