Compare commits

...
22 Commits
Author SHA1 Message Date
peter c003a48244 release: 0.104.0 (improvement/adhoc-batch-oct) 2026-10-10 00:24:27 +02:00
peter 8a2334e198 release: 0.104.0 notes and version 2026-10-10 00:15:23 +02:00
peter 1f010328df fix(plex): plain text survives a decimal reference too long to parse
int() refuses a decimal run past 4300 digits, so one such reference in a summary raised out of the sync and the text migration. Any number past seven significant digits is beyond U+10FFFF, so it becomes U+FFFD like any out-of-range reference; leading zeros are dropped first. The rails test's comment now says what it guards: both rails agree with the rollup.
2026-10-10 00:03:08 +02:00
peter 77638f7a0b fix(player): a link that opens the cut text is scrolled back into view
Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
2026-10-09 23:46:40 +02:00
peter 2cff4ad954 fix(feed): an override yields only to a refetch that set out after its write was answered
Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
2026-10-09 23:46:39 +02:00
peter 8776586ca3 fix(plex): a matched episode and a collection member offer Details in their menu
Their titles open the item's page, a pointer-only door; the card menu is the keyboard's.
2026-10-09 23:46:39 +02:00
peter 92a17aba83 fix(plex): a reshuffle survives a refresh landing mid-deal; the ↻ spins for a deal only
The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
2026-10-09 23:46:39 +02:00
peter fc53bc03bc fix(plex): the rails' Restart comes from the one rollup; plain text decodes whole entity names only
Both Continue-watching rails built a show's card state inline; they now take it from rollup.show_state, so a show part watched with nothing to resume offers Play alone there too. A named reference counts only when its whole name is an HTML5 entity: html.unescape still decoded the legacy "&not" inside "&notice;". Tests cover the grid's in-progress-but-not-resumable case through its SQL and both rails.
2026-10-09 23:46:39 +02:00
peter 6df257d7df fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn
Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
2026-10-09 22:01:28 +02:00
peter 5572499b56 fix(player): the shut panel keeps the dialog's name; Tab onto a cut link opens the text
With the side panel shut the title heading was gone, and the dialog it named
went unnamed: the strip carries an sr-only copy. A link below a clamp's cut
stayed tabbable out of sight; reaching it now opens the text, measured from the
text's top since focus scrolls the clamped box. DESIGN describes the modal as
built, with log rows for its chrome and the wheel rule.
2026-10-09 22:01:27 +02:00
peter 76738f8ea9 fix(plex): plain text decodes only well-formed character references
html.unescape also decodes the legacy forms without a semicolon, so prose like
"AT&notice" came out as "AT¬ice". The show and collection sync paths are
now tested too.
2026-10-09 22:01:27 +02:00
peter 568bf93510 fix(feed): an optimistic write outlives only the server answers older than it
A reset wrote a position mask over the cached list, so a replay's saved
position stayed hidden: a reopen resumed at 0 and its first checkpoint wrote
over the progress. The reset now writes 0 into the cache, where the player's
closing patch writes the next one. Overrides carry their write order, and a
landing refetch drops only those written before it set out. The player
session ends after the modal unmounts, so a clip auto-marked watched as it
closes still refetches the list.
2026-10-09 22:01:27 +02:00
peter 942154cf49 fix(plex): a Surprise draw stays put until its reshuffle
The rail's query was always stale, so Back from a card's player or page remounted it into a new
draw, as did a return to the window: the card just looked at was gone. It now stands until the
reshuffle (or another scope, language or filter) asks for a new one.
2026-10-09 01:29:12 +02:00
peter 3eb87e7108 feat(plex): every show card plays the episode it would put on
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.

The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
2026-10-09 01:22:00 +02:00
peter 851b97b09d fix(ui): a card's hover chrome answers keyboard focus, not any focus
Back from a channel page hands the card its focus again after a mouse trip;
revealed on focus-within, its hover row stayed up under no pointer until a
reload (owner). The row, the Play layer, the Continue + Restart pair and the
global .reveal rule now show on hover or on :focus-visible on the card or
inside it, so Tab and the arrow keys still reveal it.
2026-10-09 00:44:46 +02:00
peter ee94d1949a fix(feed): a video marked watched in the player stays out of an Unwatched view
Marked watched in the player (by hand, or by the player at its end), a card
left an Unwatched list at once, then came back as "new" when the player
closed, until a reload. The player's closing position patch writes into the
cached feed; the feed took the bumped dataUpdatedAt for fresh server data,
dropped its optimistic overrides, and nothing refetched. The overrides now
clear only when a refetch lands, and a player session that changed a state
refetches the list and its count by the view's filters on close, once its
writes have landed.
2026-10-09 00:44:46 +02:00
peter 4f076b5d45 feat(feed): a card's menu filters the feed by its channel, and back
Under "Open the channel" a video card's menu (the right-click on its
channel name included) adds the channel to the feed's channel filter, or,
when it is already in, takes it out. Channels OR together, so it widens a
filter that names others, as "Show in feed" does. Not on a channel page,
whose view is that channel already, nor in a live YouTube search or on Home.
2026-10-09 00:44:46 +02:00
peter ece74a42ab feat(player): a roomier YouTube player, its panel shut on demand
- The full-height previous/next strips beside the card are gone: the panel's
  transport row and Shift+arrows step on their own, and the video gains the
  7rem the strips' gutters took.
- Close is one icon in the card's top-right corner (the wide panel's head,
  the stacked card's title row).
- The panel's description is cut to three lines with More / Less
  (ClampedText now takes the linked text as children).
- Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for
  a bigger video; it stays shut while the queue steps on, and a video opened
  from a card starts it open. Focus moves to the counterpart toggle.
- The wheel has one meaning per place: over a panel (or the stacked card)
  with something to scroll it only scrolls, never falling through to the
  volume at the end; over the video, or a panel that fits, it is the volume.
- A long channel name is cut on the panel's line instead of running the
  20rem panel into a sideways scroll.
2026-10-09 00:27:29 +02:00
peter 600d5c39da feat(ui): a button over a picture answers the pointer with an accent ring
The media variant's hover darkened its body from 60 to 75% black, which no
one could see on a poster; a rail's scroll arrow over a card could not be told
from the card's own Play under it. Its hover is now a 2px accent ring over an
80% body (owner pick B), on every card control, Plex and YouTube, and the rail
arrows. DESIGN.md B3 names it beside the edged pills, with its log row.
2026-10-09 00:27:28 +02:00
peter 4c7f73af9d feat(plex): a title opens its page, the picture plays; an episode links up
A card title lit like a link but played the item. Now the picture plays and
the title opens the page of what it names: a movie's or an episode's info
page, a show's page, and for an episode standing as its show (Recently
watched) the show. Every poster and episode card carries it (grid, rails,
person, collection, season, related, Home), a card with no page keeps a plain
title, and the season page gains the menu's Details row too.

The episode page had no way up: the detail now serves the show's and the
season's rating keys, and on the page the show title opens the show and
"S1·E25" the season (Back returns to where the page was).
2026-10-09 00:06:18 +02:00
peter 5f42ff61c8 feat(plex): a Surprise reshuffle deals the previous draw last
The rail draws three unseen titles per kind under the sidebar filters; on a
small filtered pool every reshuffle dealt the same few again. The redraw now
sends the draw on screen (exclude=), the server deals the rest of the pool
first and tops up from the excluded titles only when the pool is short, so a
small pool still fills the rail. (The two discover.py baseline entries went
with the rewritten draw.)
2026-10-09 00:06:11 +02:00
peter 6dd82ef5e2 fix(plex): summaries and taglines are stored as plain text
A Plex agent can hand a summary over as HTML (<p> paragraphs, &aacute;
entities); the info page and the hover card showed the tags. One cleaner,
app/plex/plaintext.py, now runs on every summary and tagline the sync, the
Hungarian overlay and the rich-detail fetch store, and migration 0101 cleans
the rows written before through the same function. Paragraph breaks survive
as newlines, which the info page and the hover card now keep.
2026-10-09 00:06:02 +02:00
80 changed files with 3064 additions and 682 deletions
+11
View File
@@ -7,6 +7,17 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.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
+1 -1
View File
@@ -1 +1 @@
0.103.0
0.104.0
-16
View File
@@ -1134,22 +1134,6 @@
"endColumn": 48,
"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": [
@@ -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>`, `&aacute;`), 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
+3 -3
View File
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.discover import cards, queries
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
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
# 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)
if sh is None:
return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status, lang=lang))
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
card = cards.from_plex(show_card(sh, state, lang=lang))
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None
+7 -3
View File
@@ -256,16 +256,20 @@ def plex_in_progress(
if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
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.watched,
prog.c.inprog,
"in_progress",
).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)
).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
+18 -2
View File
@@ -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
router. Pure functions — no DB, no request state."""
from typing import NamedTuple
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
# 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
@@ -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 {
"id": sh.rating_key,
"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),
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
"status": state.status,
"resumable": state.resumable,
}
+2 -1
View File
@@ -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`)."""
from app.plex import people
from app.plex.plaintext import plain_text
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],
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"tagline": plain_text(meta.get("tagline")),
"cast": people.cast_from_meta(meta),
}
+3 -2
View File
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync
from app.plex.client import PlexClient
from app.plex.genres import hu_genres
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__)
@@ -109,8 +110,8 @@ def _overlay_pass(
for g, meta in hu_by_guid.items():
targets = by_guid[g]
title_hu = (meta.get("title") or "").strip() or None
summary_hu = meta.get("summary")
tagline_hu = (meta.get("tagline") or "").strip() or None
summary_hu = plain_text(meta.get("summary"))
tagline_hu = plain_text(meta.get("tagline"))
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
for row in targets:
row.title_hu = title_hu
+51
View File
@@ -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 `&aacute;`-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&notice" or "R&copy" came out as "AT¬ice" and "R©". A named
# one counts only when its whole name is an entity: for "&notice;" `html.unescape` still falls back
# to the legacy "&not" 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 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
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
# 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"
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:
"""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
@@ -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:
return {}
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]:
"""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 ()`)."""
if not item_ids:
return {}
+4 -3
View File
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres
from app.plex.lookups import by_rating_key
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__)
# 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).
row.guid = _canonical_guid(meta) or row.guid
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.duration_s = _dur_s(meta)
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
) # EN<->HU pairing key; keep prior if listing has none
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.thumb_key = meta.get("thumb")
sh.art_key = meta.get("art")
@@ -659,7 +660,7 @@ def _upsert_collection(
db.add(col)
col.library_id = lib_id
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.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1"
+4
View File
@@ -164,6 +164,7 @@ def unified_library(
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "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).
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"),
literal(0).label("position_seconds"),
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
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
@@ -276,6 +279,7 @@ def unified_library(
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
"resumable": r.resumable,
}
)
+3 -3
View File
@@ -24,7 +24,7 @@ from app.models import (
)
from app.plex import rollup
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.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
@@ -318,7 +318,7 @@ def collection_detail(
if shows
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}
def _movie_last_watched(st: PlexState | None) -> datetime | None:
@@ -359,7 +359,7 @@ def collection_detail(
items = [
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
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
]
years = [m.year for m in members if m.year is not None]
+25 -2
View File
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
from app import logs
from app.auth import current_user
from app.db import get_db
from app.discover import queries
from app.models import (
PlexCollection,
PlexItem,
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex.query import title_col
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.lookups import by_rating_key
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:
"""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)."""
@@ -260,7 +279,7 @@ def _relatedshow_cards(
if sh is None or rk in seen:
continue
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:
break
return out
@@ -504,6 +523,7 @@ def item_detail(
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
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
return {
"id": it.rating_key,
"kind": it.kind,
@@ -537,6 +557,9 @@ def item_detail(
"status": st.status if st else "new",
"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),
# 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,
"episode_number": it.episode_number,
"prev_id": prev_id,
+104 -37
View File
@@ -23,7 +23,13 @@ from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
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 (
LibraryFilters,
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]:
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(
@@ -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.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
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.watched,
prog.c.inprog,
@@ -243,8 +251,9 @@ def _continue_watching(
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
for sh, resumed_at in srows:
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
for sh, resumed_at, total, watched, inprog in srows:
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)
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")
def surprise(
scope: str = "both",
lang: str = "en",
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(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -419,6 +488,8 @@ def surprise(
lang = _norm_lang(lang)
p = f.as_dict()
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)
movies = []
if movie_ids:
@@ -432,23 +503,21 @@ def surprise(
)
.exists()
)
movies = (
db.execute(
_movie_meta(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
~watched_movie,
),
p,
lang,
f,
)
.order_by(func.random())
.limit(per)
)
.scalars()
.all()
movies = _draw(
db,
_movie_meta(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
~watched_movie,
),
p,
lang,
f,
),
PlexItem.rating_key,
per,
exclude,
)
shows = []
if show_ids:
@@ -458,21 +527,19 @@ def surprise(
not_fully_watched = or_(
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
)
shows = (
db.execute(
apply_meta_filters(
select(PlexShow)
.outerjoin(prog, prog.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
PlexShow,
p,
lang,
)
.order_by(func.random())
.limit(per)
)
.scalars()
.all()
shows = _draw(
db,
apply_meta_filters(
select(PlexShow)
.outerjoin(prog, prog.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
PlexShow,
p,
lang,
),
PlexShow.rating_key,
per,
exclude,
)
mcards = _movie_cards(db, user.id, movies, lang)
scards = _show_cards(db, user.id, shows, lang)
+3 -3
View File
@@ -19,7 +19,7 @@ from app.models import (
)
from app.plex import rollup
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
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.
if k.get("media_type") == "tv":
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:
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
return out[:_KNOWN_FOR_SHOWN]
@@ -245,7 +245,7 @@ def person_detail(
.all()
)
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(
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
+72 -2
View File
@@ -19,7 +19,7 @@ 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 PlexItem, PlexLibrary, PlexShow, User
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import detail
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")
db.add(show)
db.flush()
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
db.add(season)
db.flush()
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.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
body = resp.json()
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"]
db.refresh(ep)
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": 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
+53
View File
@@ -165,6 +165,30 @@ def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
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):
# 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.
@@ -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
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):
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
+159
View File
@@ -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&aacute;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&aacute;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> &amp; <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&notice, R&copy and H&M", "AT&notice, R&copy and H&M"),
# ...and only a whole entity name: a legacy one at its start ("&not", "&amp") is not.
("AT&notice; and &ampx;", "AT&notice; and &ampx;"),
("&#233;t&#xE9; &copy;", "été ©"),
# A decimal reference too long for int() to parse is out of range, like &#x110000;.
("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 &amp; B</p>"})
assert col.summary == "A & B"
def test_a_fetched_tagline_is_plain_text():
assert rich_detail({"tagline": "<i>An offer</i> &amp; 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 &eacute;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 &amp; 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")
+40 -7
View File
@@ -18,6 +18,7 @@ from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import rollup, sync
from app.plex.cards import ShowState
def _dt(day: int) -> datetime:
@@ -184,21 +185,53 @@ class TestRollup:
def test_state_map_new_inprogress_watched(self, db):
s = _seed(db)
show, user = s["show"], s["user"]
# e1 in-progress, e2 new -> in_progress
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "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: ShowState("in_progress", True)}
# empty input short-circuits
assert rollup.state_map(db, user.id, []) == {}
# a fresh user with no state -> new
other = User(email="rollup-other@example.com")
db.add(other)
db.commit()
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
# mark every episode watched for `user` -> watched
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
# 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))
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
e1, e2 = db.execute(
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()
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):
+129 -114
View File
@@ -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%**
(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
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
exception is 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).
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The
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); 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
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).
@@ -398,7 +400,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
`mediaTitleClass`, `mediaPictureBox`).
- **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
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.
- **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
@@ -411,7 +415,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
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
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
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
@@ -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
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.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
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)
@@ -609,109 +620,113 @@ of a full-width panel and nothing on the right.
## Decision log
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 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 | 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 | 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 | 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-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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 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 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 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 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-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-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 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 | 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 | 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 | 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-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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 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 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 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 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-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-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | 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 |
@@ -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();
});
});
+60 -20
View File
@@ -5,7 +5,7 @@
// 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.
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";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -35,6 +35,7 @@ vi.mock("./VirtualFeed", () => ({
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 {
@@ -78,30 +79,69 @@ const halfWatched = {
watch_url: "https://www.youtube.com/watch?v=v1",
} 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", () => {
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.
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) 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>,
);
mount(() => new Promise(() => {}));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
fireEvent.click(screen.getByText("reset v1"));
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());
});
});
+125 -48
View File
@@ -27,6 +27,7 @@ import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../lib/progressCache";
import {
buildSort,
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.
const YT_COUNTS = [20, 40, 60, 100] as const;
@@ -146,26 +163,59 @@ export default function Feed({
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// A reset drops the resume position too, which a status override cannot say: without this the card
// kept its progress bar and its Continue button until the next refetch.
const [resetIds, setResetIds] = useState<Record<string, true>>({});
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
// The write order the stamps above count in, the writes the server has answered, and which of
// them it had answered when the running refetch set out.
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).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null,
);
const qc = useQueryClient();
const openVideo = useCallback(
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[],
);
// What changed state while the player was open, and the writes still in flight: closing it
// 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
// 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
// debounced volume write on each pass.
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;
// 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);
setOverrides({});
setSavedOverrides({});
setResetIds({});
}
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
const pagesLenRef = useRef(0);
const pagesLen = query.data?.pages?.length ?? 0;
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
// 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(() => {
const appended = pagesLen > pagesLenRef.current;
pagesLenRef.current = pagesLen;
if (!appended) {
setOverrides({});
setSavedOverrides({});
setResetIds({});
const started = !wasRefetching.current && refetching;
const landed = wasRefetching.current && !refetching && query.status === "success";
wasRefetching.current = refetching;
if (started) answeredAtRefetch.current = new Set(answered.current);
if (!landed) return;
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):
// 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 onState = useCallback(
(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.
// 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
// would falsely report a change that the .catch below is about to roll back (e.g. when
// the API is unreachable).
api
.setState(id, status)
write
.then(() => {
// 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
// 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.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
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:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -354,24 +418,27 @@ export default function Feed({
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
setResetIds((r) => ({ ...r, [id]: true }));
const seq = ++writeSeq.current;
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
.clearState(id)
// 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) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setResetIds((r) => {
const next = { ...r };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return;
resets.current.delete(id);
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
});
},
[qc],
@@ -379,9 +446,11 @@ export default function Feed({
const onToggleSave = useCallback(
(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))
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
@@ -392,7 +461,7 @@ export default function Feed({
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -409,12 +478,11 @@ export default function Feed({
list
.map((v) => {
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) => {
const saved = savedOverrides[v.id];
return saved !== undefined ? { ...v, saved } : v;
return saved ? { ...v, saved: saved.value } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
@@ -878,6 +946,15 @@ export default function Feed({
onToggleSave={onToggleSave}
onResetState={onResetState}
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
// 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.
+15 -2
View File
@@ -663,7 +663,7 @@ describe("Home", () => {
comfort: [],
});
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" }));
});
@@ -678,11 +678,24 @@ describe("Home", () => {
comfort: [],
});
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(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 () => {
// `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"
+1
View File
@@ -371,6 +371,7 @@ function Rail({
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
onPage={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
@@ -424,23 +424,34 @@ 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
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => {
it("stand inside the row's gutter", () => {
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
// width (owner): the panel's transport row and Shift+← / → step on their own.
describe("PlayerModal — no side strips", () => {
it("steps from the panel only, and the card keeps no gutter for strips", () => {
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 arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
(b) => b.className.includes("w-12"),
)!;
expect(arrow).toBeTruthy();
expect(arrow.className).toMatch(/\bright-0\b/);
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.className).not.toMatch(/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);
});
});
@@ -531,9 +542,7 @@ describe("PlayerModal — fits the window's height", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe(
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
);
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
@@ -587,13 +596,55 @@ describe("PlayerModal — the side panel on a wide window", () => {
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
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.
expect(card.parentElement!.style.maxWidth).toBe(
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
// The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
// 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);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
@@ -614,11 +665,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
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(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;
expect(spin(text, 100)).toBe(true); // at its end: the volume
expect(spin(text, -100)).toBe(false);
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
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.
+332 -265
View File
@@ -21,14 +21,16 @@ import {
ArrowLeft,
Check,
CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink,
Info,
Maximize,
Minimize,
Minimize2,
PanelRightClose,
PanelRightOpen,
Settings,
SkipBack,
SkipForward,
Trash2,
Volume2,
VolumeX,
@@ -74,7 +76,6 @@ import {
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings";
@@ -105,6 +106,8 @@ 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.
const WIDE_PLAYER_PX = 1280;
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).
const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
@@ -248,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
// 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);
// 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(() => {
const card = cardRef.current;
const stage = stageRef.current;
@@ -304,6 +324,8 @@ export default function PlayerModal({
const navigated = currentVideoId !== active.id;
// 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);
// 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
// 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.
@@ -646,9 +668,22 @@ export default function PlayerModal({
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 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
// must not render inside the page scroller: once that scroller is a stacking context (its
@@ -665,15 +700,13 @@ export default function PlayerModal({
aria-modal="true"
aria-labelledby={titleId}
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
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. */}
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
for a second copy of the same two buttons (owner). */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
className="relative flex w-full max-h-full"
// The design width (56rem), narrowed so the 16:9 video plus the chrome under it fits the
// 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,
// 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.
@@ -682,27 +715,15 @@ export default function PlayerModal({
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
style={{
maxWidth: wide
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
? `calc(${fitWidth} + ${panelWidth})`
: `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
ref={cardRef}
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 ${
wide ? "flex overflow-hidden" : "overflow-y-auto"
}`}
@@ -710,6 +731,8 @@ export default function PlayerModal({
>
<div
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 ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
@@ -891,275 +914,319 @@ export default function PlayerModal({
description never makes the card taller than the video. */}
<div
className={wide ? "relative shrink-0 border-l border-border" : undefined}
style={wide ? { width: SIDE_PANEL } : undefined}
style={wide ? { width: panelWidth } : undefined}
>
<div
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
data-testid="player-info"
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
data-wheel-scrolls={wide ? "" : undefined}
>
{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}
/>
</div>
)}
<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 && (
{wide && panelShut ? (
<div
data-testid="player-strip"
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
>
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
<h2 id={titleId} className="sr-only">
{heading}
</h2>
{closeButton}
<IconButton
ref={panelToggleRef}
variant="ghost"
onClick={() => togglePanel(false)}
label={t("player.expandPanel")}
aria-expanded={false}
>
<PanelRightOpen />
</IconButton>
{hasQueue && (
<>
<span className="flex-1" />
<IconButton
ref={setDescAnchor}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
onClick={goPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<Info />
<SkipBack />
</IconButton>
)}
{navigated && (
<Button
<IconButton
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
onClick={goNext}
disabled={index >= queue!.length - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
{t("player.back")}
</Button>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<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}
<SkipForward />
</IconButton>
</>
)}
</div>
) : (
<div
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
data-testid="player-info"
// A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
data-wheel-scrolls={wide ? "" : undefined}
>
{wide && (
<div className="flex items-center justify-between px-2 pt-2">
<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>
<Button
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
>
{t("player.close")}
<X />
</Button>
</div>
</div>
)}
{/* Channel + meta on one line, with the actions pushed to the right.
<div className="p-4 sm:p-5">
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
right (wide, it heads the panel). */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{heading}
</h2>
{!wide && (
<IconButton
ref={setDescAnchor}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
>
<Info />
</IconButton>
)}
{navigated && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
{t("player.back")}
</Button>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<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}
/>
</PopoverSurface>
{!wide && closeButton}
</div>
{/* 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 shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-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"
<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"
/>
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
className="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"}`}
>
<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 && (
<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}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
})}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</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>
)}
{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 —
{/* 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 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
the docked bar offers OUR true fullscreen. */}
{!navigated && (
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
{!navigated && (
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline-danger"
variant="outline"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Trash2 />
<Maximize />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
)}
</div>
{wide && (
<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>
{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>
{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>
{/* 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
@@ -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();
});
});
+37 -3
View File
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlayShow } from "./usePlayShow";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
@@ -328,18 +329,32 @@ export default function PlexBrowse() {
return () => io.disconnect();
}, [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) {
if (card.type === "show") return playShow(card, false);
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
if (card.type === "show") sub.open({ kind: "show", id: card.id });
else sub.open({ kind: "player", id: card.id });
sub.open({ kind: "player", id: card.id });
}
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
function onRestartCard(card: PlexCard) {
if (card.type === "show") return playShow(card, true);
scrollRef.current = scroller()?.scrollTop ?? 0;
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) {
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
sub.open({ kind: "info", id: card.id });
}
@@ -377,8 +392,9 @@ export default function PlexBrowse() {
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
menu.open,
);
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
const epActions = usePlexCardActions(
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
epMenu.open,
);
// 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 })}
onFilter={applyFilter}
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 })}
onOpenShow={(id) => sub.open({ kind: "show", id })}
onPlayShow={playShow}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
@@ -504,6 +535,7 @@ export default function PlexBrowse() {
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onInfoCard={onInfo}
onPlay={(epRk, queue, fromStart) =>
sub.open({ kind: "player", id: epRk, queue, fromStart })
}
@@ -707,6 +739,7 @@ export default function PlexBrowse() {
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onPage={onInfo}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
@@ -730,6 +763,7 @@ export default function PlexBrowse() {
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onPage={() => onInfo(ep)}
onRestart={() => onRestartCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
onAdd={() => epActions.add(ep)}
@@ -431,6 +431,7 @@ export default function PlexCollectionView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
}));
import PlexDiscover from "./PlexDiscover";
import { qk } from "../lib/queryKeys";
import type { PlexCard } from "../lib/api";
afterEach(() => {
cleanup();
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
expect.objectContaining({ genres: [] }),
]);
expect(keys).toContainEqual([
"plex-surprise",
"plex-discover",
"surprise",
"both",
"en",
expect.objectContaining({ genres: [] }),
@@ -218,6 +221,103 @@ describe("PlexDiscover", () => {
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
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 () => {
@@ -244,8 +344,8 @@ describe("PlexDiscover", () => {
});
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
// refused toggle stayed flipped there — the sibling of the collection page's fix.
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
// left flipped until that read lands — the sibling of the collection page's fix.
plexDiscover.mockResolvedValue({
...fullPayload,
continue_watching: [],
+46 -8
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
@@ -78,6 +78,7 @@ function CardRail({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onRestart={() => onRestartCard(c)}
onToggleWatched={onToggleWatched}
onAdd={actions.add}
@@ -119,11 +120,46 @@ export default function PlexDiscover({
queryFn: () => api.plexDiscover(scope, lang, filters),
});
// 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({
queryKey: qk.plexSurprise(scope, lang, filters),
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
staleTime: 0,
queryKey: surpriseKey,
queryFn: () => {
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
// 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);
},
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
// toggle was left flipped there — put back in place instead.
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
// refused toggle is put back at once rather than left flipped until that read lands.
revert: () => setSurpriseStatus(card, card.status),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
@@ -228,8 +264,10 @@ export default function PlexDiscover({
cards={surprise}
action={
<IconButton
onClick={() => surpriseQ.refetch()}
loading={surpriseQ.isFetching}
onClick={reshuffle}
// 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")}
>
<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
// 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
@@ -362,4 +411,18 @@ describe("a collection strip's card", () => {
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
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"));
});
});
+44 -3
View File
@@ -11,6 +11,8 @@ import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAsi
import {
DETAIL_POSTER,
episodeLine,
episodeName,
episodeNumbers,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
@@ -186,6 +188,11 @@ type Props = {
onPlayItem: (id: string, fromStart?: boolean) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
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({
@@ -198,6 +205,9 @@ export default function PlexInfo({
onOpenPerson,
onPlayItem,
library,
onOpenShow,
onOpenSeason,
onOpenInfo,
}: Props) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -275,10 +285,13 @@ export default function PlexInfo({
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(
{
onOpen: (c) => onPlayItem(c.id),
onRestart: (c) => onPlayItem(c.id, true),
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
onToggleWatched: toggleColWatched,
},
colMenu.open,
@@ -299,6 +312,12 @@ export default function PlexInfo({
const asideShown = !overlay && aside.column;
const infoTitle =
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 (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
@@ -312,11 +331,32 @@ export default function PlexInfo({
{overlay ? (
<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" && (
<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>
)}
</div>
@@ -468,7 +508,7 @@ export default function PlexInfo({
{detail.summary && (
<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}
</p>
@@ -576,6 +616,7 @@ export default function PlexInfo({
shelf
navIndex={i}
onOpen={() => onPlayItem(m.id)}
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
onRestart={() => onPlayItem(m.id, true)}
onToggleWatched={toggleColWatched}
onAdd={colActions.add}
+27 -3
View File
@@ -161,10 +161,34 @@ describe("a Plex title's menu", () => {
expect(labels()).not.toContain("card.restartTitle");
});
it("a show opens its page and has no watched, add or details rows", () => {
setup(card({ type: "show" }));
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
// 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();
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", () => {
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
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", () => {
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
const { status } = mount({
+1 -1
View File
@@ -295,7 +295,7 @@ function Thumb({
</span>
)}
{/* 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
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
{onOpen && (
+8 -1
View File
@@ -3,6 +3,7 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import type { NEXT_PAGE } from "../lib/nextPage";
import VideoCard from "./VideoCard";
import { VideoMenu } from "./VideoMenu";
import type { VideoActionHandlers } from "./useVideoActions";
import { useContextMenu } from "./ui/useContextMenu";
import VirtualGrid from "./ui/VirtualGrid";
@@ -28,6 +29,8 @@ export interface VirtualFeedProps {
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => 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;
isFetching: boolean;
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
@@ -43,6 +46,7 @@ export default function VirtualFeed({
onToggleSave,
onResetState,
onOpen,
channelFilter,
hasNextPage,
isFetching,
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 videoCard from "./VideoCard.tsx?raw";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import {
MEDIA_BAR,
@@ -38,3 +40,28 @@ describe("a media picture", () => {
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 { Button } from "./ui/Button";
import { StateMessage } from "./ui/QueryState";
import { ClampedText } from "./ui/ClampedText";
import { renderDescription } from "./descriptionLinks";
// 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 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).
* `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({
detail,
currentId,
@@ -118,6 +120,7 @@ export function PlayerDescription({
className,
style,
ref,
clamp = false,
}: {
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
currentId: string;
@@ -126,6 +129,7 @@ export function PlayerDescription({
className: string;
style?: CSSProperties;
ref?: Ref<HTMLDivElement>;
clamp?: boolean;
}) {
const { t } = useTranslation();
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;
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 (
<div ref={ref} style={style} className={className}>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
+111 -8
View File
@@ -5,7 +5,9 @@
// 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.
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) => {
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 { 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 => ({
id: "1",
@@ -169,14 +179,70 @@ describe("PlexPosterCard with a next episode", () => {
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(
<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(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
expect(playGlyph(container)).not.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();
});
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;
@@ -313,7 +379,7 @@ describe("Plex cards in a keyboard-walkable list", () => {
describe("a Plex card's title stays selectable", () => {
it("opens from its caption, but not on the click that ends selecting the title", () => {
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 range = document.createRange();
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", () => {
it("does not open on a press dragged past the slop", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
const title = screen.getByText("M");
fireEvent.mouseDown(title, { clientX: 10, 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,
// so an assertion can see which words went into the line.
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) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -17,6 +19,14 @@ vi.mock("react-i18next", async (importOriginal) => {
import { PlexPosterCard, SeasonCard } from "./plexCards";
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);
const watchedEpisode: PlexCard = {
@@ -4,11 +4,21 @@
// 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.
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 { PlexPosterCard } from "./plexCards";
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(() => {
cleanup();
setLanguage("en");
+62 -21
View File
@@ -2,10 +2,14 @@ import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx";
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 { Carousel } from "./ui/Carousel";
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 { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -30,8 +34,9 @@ import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
import { cardImage } from "../lib/plexImage";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
// 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;
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
@@ -74,7 +79,7 @@ function HoverBar({
onToggleWatched?: () => void;
addLabel: string;
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;
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
* 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. */
/** A Plex card's title: a link to the page of what it names (a movie's or an episode's info page, a
* show's page), while the picture plays it — a title lit like a link that started playback surprised
* (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({
onClick,
shelf = false,
@@ -267,10 +274,14 @@ export function PlexPosterCard({
next,
onInfo,
onRestart,
onPage,
}: {
card: PlexCard;
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;
onToggleWatched?: (c: PlexCard) => void;
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
@@ -290,11 +301,12 @@ export function PlexPosterCard({
wide?: boolean;
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
shelf?: boolean;
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
* names it, and an ⓘ (`onInfo`) opens the show. */
/** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
* add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
* 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;
/** The show's page, beside a `next` episode. */
/** The show's page, on the ⓘ (`onPage` where a surface has no other). */
onInfo?: () => void;
}) {
const { t, i18n } = useTranslation();
@@ -306,9 +318,24 @@ export function PlexPosterCard({
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
const landscape = wide || (card.type === "episode" && !asShow);
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep;
const titled: PlexCard = asShow
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
: 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.
const acted = ep ?? card;
const watched = card.status === "watched";
@@ -320,6 +347,8 @@ export function PlexPosterCard({
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// The next episode by name: its numbers, or its title when Plex has none for it.
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 movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner =
@@ -354,10 +383,10 @@ export function PlexPosterCard({
label={name}
onOpen={onOpen}
onRestart={onRestart}
plays={isPlayable}
plays
small={shelf && landscape}
pct={pct}
started={plexStarted(acted)}
started={started}
bar={
<HoverBar
watched={actedWatched}
@@ -373,7 +402,7 @@ export function PlexPosterCard({
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(acted))}
onAdd={add}
onInfo={ep ? onInfo : undefined}
onInfo={showPage}
more={m}
/>
}
@@ -385,7 +414,7 @@ export function PlexPosterCard({
</PlexPicture>
}
title={
<CardTitle onClick={onOpen} shelf={shelf}>
<CardTitle onClick={onPage && (() => onPage(titled))} shelf={shelf}>
{asShow ? card.show_title : card.title}
</CardTitle>
}
@@ -423,6 +452,7 @@ export function PlexPosterCard({
{rating}
</span>
}
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
summary={card.summary}
/>
</>
@@ -628,12 +658,18 @@ export function CastRow({
export function RelatedStrip({
related,
onOpen,
onPlay,
onRestart,
menuProps,
menuButtonProps,
openId = null,
}: {
related: PlexCard[];
/** A related show's page (its title and its ⓘ). */
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). */
menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
@@ -654,7 +690,9 @@ export function RelatedStrip({
card={r}
shelf
navIndex={i}
onOpen={() => onOpen(r)}
onOpen={() => onPlay(r)}
onRestart={() => onRestart(r)}
onPage={onOpen}
menuProps={menuProps}
menuButtonProps={menuButtonProps}
menuOpen={openId === r.id}
@@ -677,9 +715,12 @@ export function EpisodeCard({
menuButtonProps,
menuOpen = false,
navIndex,
onPage,
}: {
ep: PlexCard;
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). */
onRestart?: () => void;
onAdd?: () => void;
@@ -732,7 +773,7 @@ export function EpisodeCard({
</PlexPicture>
}
title={
<CardTitle onClick={onPlay}>
<CardTitle onClick={onPage}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)}
@@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
onPlay={() => {}}
onOpenSeason={() => {}}
onOpenShow={() => {}}
onPlayShow={() => {}}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
@@ -108,7 +108,15 @@ const ALL = ["e11", "e12", "e21", "e22"];
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 () => {
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/ });
expect(btn.textContent).toContain("S2 · E1");
fireEvent.click(btn);
@@ -124,6 +132,7 @@ describe("the restart button on a show and a season", () => {
onPlay={onPlay}
onOpenSeason={() => {}}
onOpenShow={() => {}}
onPlayShow={() => {}}
/>,
);
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
@@ -146,6 +155,7 @@ describe("the show page's summary", () => {
onPlay={() => {}}
onOpenSeason={() => {}}
onOpenShow={() => {}}
onPlayShow={() => {}}
/>,
);
fireEvent.click(await screen.findByText("plex.textMore"));
+28 -1
View File
@@ -78,6 +78,9 @@ export function PlexInfoView({
onPlayItem,
onFilter,
onOpenPerson,
onOpenShow,
onOpenSeason,
onOpenInfo,
}: {
id: string;
onBack: () => void;
@@ -85,6 +88,9 @@ export function PlexInfoView({
onPlayItem: (id: string, fromStart?: boolean) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
onOpenShow: (showId: string) => void;
onOpenSeason: (showId: string, seasonId: string) => void;
onOpenInfo: (id: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = plexLang(i18n.language);
@@ -113,6 +119,9 @@ export function PlexInfoView({
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
onOpenShow={onOpenShow}
onOpenSeason={onOpenSeason}
onOpenInfo={onOpenInfo}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
@@ -238,6 +247,7 @@ export function PlexPersonView({
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onToggleWatched={toggle}
{...cardMenu(c)}
/>
@@ -372,6 +382,7 @@ export function PlexPersonView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onToggleWatched={toggle}
{...cardMenu(c)}
/>
@@ -411,6 +422,7 @@ export function PlexShowView({
onPlay,
onOpenSeason,
onOpenShow,
onPlayShow,
onFilter,
onOpenPerson,
}: {
@@ -419,6 +431,8 @@ export function PlexShowView({
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
onOpenSeason: (seasonId: 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;
onOpenPerson?: (name: string) => void;
}) {
@@ -435,7 +449,14 @@ export function PlexShowView({
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
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 copy = useCopyItems();
@@ -732,6 +753,8 @@ export function PlexShowView({
<RelatedStrip
related={d.related}
onOpen={(c) => onOpenShow(c.id)}
onPlay={(c) => onPlayShow(c, false)}
onRestart={(c) => onPlayShow(c, true)}
menuProps={relatedMenu.triggerProps}
menuButtonProps={relatedMenu.buttonProps}
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
@@ -765,11 +788,13 @@ export function PlexSeasonView({
seasonId,
onBack,
onPlay,
onInfoCard,
}: {
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
onInfoCard: (card: PlexCard) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -822,6 +847,7 @@ export function PlexSeasonView({
{
onOpen: (ep) => onPlay(ep.id, allKeys),
onRestart: (ep) => onPlay(ep.id, allKeys, true),
onInfo: onInfoCard,
onToggleWatched: (ep) => void markEpisode(ep),
},
epMenu.open,
@@ -918,6 +944,7 @@ export function PlexSeasonView({
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onPage={() => onInfoCard(ep)}
onRestart={() => onPlay(ep.id, allKeys, true)}
onAdd={() => episodes.add(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", () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
+34 -7
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
import { Button } from "./Button";
// 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
* 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
* 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({
text,
lines,
moreLabel,
lessLabel,
className,
textClassName = "measure whitespace-pre-line text-sm leading-relaxed text-muted",
children,
}: {
text: string;
lines: keyof typeof CLAMP;
moreLabel: string;
lessLabel: 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 [clipped, setClipped] = useState(false);
@@ -29,7 +37,7 @@ export function ClampedText({
setShown(text);
setExpanded(false);
}
const ref = useRef<HTMLParagraphElement>(null);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || expanded) return;
@@ -39,14 +47,33 @@ export function ClampedText({
ro.observe(el);
return () => ro.disconnect();
}, [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 (
<div className={className}>
<p
<div
ref={ref}
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`}
className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}
onFocus={revealFocused}
>
{text}
</p>
{children ?? text}
</div>
{(clipped || expanded) && (
<Button
variant="link"
+9 -1
View File
@@ -18,12 +18,15 @@ export function MediaPreview({
preview,
title,
facts,
note,
summary,
}: {
preview: ReturnType<typeof useMediaPreview>;
title: string;
/** One line under the title: year, length, score. */
facts?: ReactNode;
/** A line of its own under the facts (a show's "Next: S6·E16 — Title"). */
note?: ReactNode;
summary?: string | null;
}) {
return (
@@ -36,7 +39,12 @@ export function MediaPreview({
>
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
{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>
);
}
+5 -2
View File
@@ -46,7 +46,10 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
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.
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":
"font-medium border border-danger/60 text-danger state-layer act:hover:border-danger",
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",
// 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).
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",
};
@@ -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.
* Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */
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
* 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"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlayerModal.tsx": [2, "previous / next arrows"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
+7 -3
View File
@@ -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
// "…" 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 =
"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. */
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 {
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 root's own (its menu's right-click and long-press). */
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();
});
});
+50
View File
@@ -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);
};
}
+18 -10
View File
@@ -17,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.
export interface PlexActionHandlers {
/** Play it: a show, the episode it would play (the surface resolves it). */
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;
onToggleWatched?: (c: PlexCard) => void;
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
@@ -32,13 +34,18 @@ const icon = "w-4 h-4";
export const plexAddLabelKey = (c: PlexCard): string =>
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";
/** 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 =>
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
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
@@ -73,6 +80,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
const isShow = c.type === "show";
const watched = c.status === "watched";
const started = plexStarted(c);
return [
@@ -80,8 +88,8 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
? [
{
id: "play",
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
label: started ? t("card.continue") : t("plex.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(c),
},
// The same words and glyph as the video card's menu (useVideoActions).
@@ -131,18 +139,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
},
]
: []),
...(playable && onInfo
...(onInfo
? [
{
id: "details",
label: t("plex.info.title"),
icon: <Info className={icon} />,
label: isShow ? t("plex.openShow") : t("plex.info.title"),
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
separatorBefore: true,
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) : []),
];
};
@@ -55,3 +55,52 @@ describe("the video menu's download row", () => {
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);
});
});
+26 -1
View File
@@ -8,6 +8,8 @@ import {
Download,
Eye,
EyeOff,
Filter,
FilterX,
Info,
ListPlus,
Play,
@@ -39,6 +41,9 @@ export interface VideoActionHandlers {
onResetState?: (id: string) => void;
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
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";
@@ -65,7 +70,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
anchor: Element | null,
{ pin: withPin = true }: { pin?: boolean } = {},
): ActionMenuItem[] => {
const { onState, onToggleSave, onResetState, onOpen } = handlers;
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
const watched = v.status === "watched";
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
@@ -112,6 +117,26 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
icon: <Tv className={icon} />,
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",
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
@@ -1,4 +1,15 @@
[
{
"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",
@@ -1,4 +1,15 @@
[
{
"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",
+3 -1
View File
@@ -24,6 +24,8 @@
"unmarkWatched": "Mark unwatched",
"unsave": "Remove from saved",
"more": "More actions",
"openChannel": "Open the channel"
"openChannel": "Open the channel",
"channelFilterAdd": "Filter by this channel",
"channelFilterRemove": "Remove the channel from the filter"
}
}
+4
View File
@@ -24,6 +24,10 @@
"description": "Description",
"noDescription": "No description.",
"descriptionFailed": "Couldn't load the description",
"descriptionMore": "More",
"descriptionLess": "Less",
"collapsePanel": "Hide the details",
"expandPanel": "Show the details",
"channel": "Channel",
"views": "{{formattedCount}} views",
"stream": "stream",
+1
View File
@@ -127,6 +127,7 @@
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "Season {{n}}",
"specials": "Specials",
"playShowFailed": "Couldn't find the episode to play — please try again",
"watchedFailed": "Couldn't update the watched state — please try again",
"seasons_one": "{{count}} season",
"seasons_other": "{{count}} seasons",
+3 -1
View File
@@ -24,6 +24,8 @@
"unmarkWatched": "Megnézés visszavonása",
"unsave": "Eltávolítás a mentettek közül",
"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"
}
}
+4
View File
@@ -24,6 +24,10 @@
"description": "Leírás",
"noDescription": "Nincs leírás.",
"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",
"views": "{{formattedCount}} megtekintés",
"stream": "élő adás",
+1
View File
@@ -127,6 +127,7 @@
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "{{n}}. évad",
"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",
"seasons_one": "{{count}} évad",
"seasons_other": "{{count}} évad",
+5 -3
View File
@@ -787,7 +787,8 @@ body {
`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
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). */
@media (hover: hover) {
.reveal {
@@ -795,7 +796,8 @@ body {
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.group:focus-visible .reveal,
.group:has(:focus-visible) .reveal,
.reveal:focus-visible {
opacity: 1;
}
@@ -810,7 +812,7 @@ body {
@media (hover: none) {
/* 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
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
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)
+14 -2
View File
@@ -49,6 +49,8 @@ export interface PlexCard {
status?: string; // new | watched | hidden
position_seconds?: number;
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
episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist)
@@ -180,6 +182,8 @@ export interface PlexItemDetail {
status: string;
position_seconds: number;
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;
episode_number?: number | null;
prev_id?: string | null;
@@ -624,22 +628,30 @@ export const plexApi = {
if (filters) appendPlexFilters(u, filters);
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
// the active filters like the other rails.
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻), narrowed by the
// 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: (
scope: string,
limit?: number,
filters?: PlexFilters,
lang?: string,
{ exclude = [], keep = [] }: { exclude?: string[]; keep?: string[] } = {},
): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope });
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 (filters) appendPlexFilters(u, filters);
return req(`/api/plex/surprise?${u.toString()}`);
},
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
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.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
@@ -14,7 +14,7 @@ import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { createRef, type RefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache";
import { patchCachedProgress, progressFlushes } from "./progressCache";
import { qk } from "./queryKeys";
import { api, type Video } from "./api";
import type { YTPlayer } from "./youtube";
@@ -113,6 +113,27 @@ describe("useYtProgress — the checkpoint reaches the cache", () => {
expect(cachedPos(qc)).toBe(830);
});
it("hands its closing checkpoint out until it has landed and been patched in", async () => {
// A list refetched on the player's close waits for it: read before it landed, the list held the
// older position and, landing after the patch, put it back.
const qc = new QueryClient();
seedFeed(qc, video());
atPosition(830, 3118);
let land: (v: unknown) => void = () => {};
vi.spyOn(api, "saveProgress").mockReturnValue(new Promise((r) => (land = r)) as never);
const { unmount } = mountPlayer(qc, new Map());
await settle();
unmount();
const flushes = progressFlushes();
expect(flushes).toHaveLength(1);
land({ video_id: VID, position_seconds: 830 });
await flushes[0];
expect(cachedPos(qc)).toBe(830);
await settle();
expect(progressFlushes()).toHaveLength(0);
});
it("mirrors what the SERVER stored, not what the player read", async () => {
const qc = new QueryClient();
seedFeed(qc, video({ position_seconds: 748 }));
+24 -19
View File
@@ -19,7 +19,7 @@ import {
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache";
import { patchCachedProgress, trackProgressFlush } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
@@ -77,7 +77,7 @@ function loadYouTubeApi(): Promise<YTNamespace> {
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area —
* except over a `[data-wheel-scrolls]` panel that can still scroll that way. */
* except over a `[data-wheel-scrolls]` panel that has anything to scroll, which keeps the wheel. */
export function useYtVolume({
playerRef,
dialogRef,
@@ -154,15 +154,17 @@ export function useYtVolume({
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
// holds the description, which a wheel spent on the volume could never be read past.
const panel =
e.target instanceof Element ? e.target.closest<HTMLElement>("[data-wheel-scrolls]") : null;
// One gesture, one meaning (owner): over a panel with something to scroll the wheel only ever
// scrolls it — at its end too, where it once fell through to the volume mid-spin. Over the video,
// or a panel that fits, it is the volume. The nearer mark wins: the video inside a stacked card
// that scrolls is still the volume.
const zone =
e.target instanceof Element
? e.target.closest<HTMLElement>("[data-wheel-scrolls],[data-wheel-volume]")
: null;
if (
panel &&
(e.deltaY < 0
? panel.scrollTop > 0
: panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights
zone?.hasAttribute("data-wheel-scrolls") &&
zone.scrollHeight > zone.clientHeight + 1 // +1: sub-pixel heights
)
return;
e.preventDefault();
@@ -525,15 +527,18 @@ export function useYtProgress({
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
void Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPos);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});
// Tracked, so a list refetch on the player's close waits for it (`progressFlushes`).
trackProgressFlush(
Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPos);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
}),
);
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
try {
+2
View File
@@ -334,6 +334,8 @@ export function usePlexProgress(opts: {
finishRef.current = () => {};
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
// The Discover rails (Continue watching, the Surprise draw's cards) hold watch state too.
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
};
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
+3 -4
View File
@@ -7,10 +7,9 @@ import { notifyActionError } from "./actionErrors";
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
// browse grid and the person page, each of which owns a different cache shape.
//
// `revert` is for state `resync` cannot correct, called on a failure only to put the card back: a
// cache it must NOT refetch — a random draw (Discover's Surprise rail), which a refetch would
// reshuffle under the viewer — or a component's local copy of the rows (a Plex playlist), which an
// identical refetch never re-seeds (react-query keeps the same `data`).
// `revert` is called on a failure only, to put the card back where `resync` does not, or not at once:
// a component's local copy of the rows (a Plex playlist), which an identical refetch never re-seeds
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState(
card: PlexCard,
t: TFunction,
+13
View File
@@ -25,6 +25,19 @@ const isItemList = (v: unknown): v is ItemList =>
* read: the endpoint clears trivially-early and near-finished checkpoints to 0, and mirroring our
* own reading would re-introduce that disagreement on the client.
*/
// The player's closing flushes still on their way: its last checkpoint and the patch above. A list
// refetched before one lands read the older checkpoint and, landing after the patch, put it back.
const flushes = new Set<Promise<unknown>>();
/** Track a closing flush until it settles (`progressFlushes` hands out the ones still running). */
export function trackProgressFlush(flush: Promise<unknown>): void {
flushes.add(flush);
const settled = () => void flushes.delete(flush);
flush.then(settled, settled);
}
export const progressFlushes = (): Promise<unknown>[] => [...flushes];
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return;
+4 -3
View File
@@ -89,12 +89,13 @@ describe("qk — variadic composite plex keys", () => {
FILTERS,
]);
expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([
"plex-surprise",
"plex-discover",
"surprise",
"movie",
"hu",
FILTERS,
]);
// A bare call stays a prefix for the reshuffle/watched-toggle invalidations.
expect(qk.plexSurprise()).toEqual(["plex-surprise"]);
// Under the discover root: whatever refreshes the rails refreshes the draw's cards too.
expect(qk.plexSurprise()).toEqual(["plex-discover", "surprise"]);
});
});
+11 -7
View File
@@ -118,15 +118,15 @@ export const qk = {
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
// Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
// refetches it alone.
// Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches it and
// the Surprise draw below for a prefix invalidation after a watched-toggle.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre
// filter narrows against the language-specific genre column, like the grid). The ↻ reshuffle is the
// query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the
// card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer.
plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest],
// filter narrows against the language-specific genre column, like the grid). UNDER the discover root,
// so whatever refreshes the rails (a watched toggle, a player closing) refreshes this one too: its
// refetch reads the same draw again (`keep`), and only the ↻ deals a new one. As a sibling root the
// draw's cards kept the watch state they were drawn with.
plexSurprise: (...rest: unknown[]) => ["plex-discover", "surprise", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
// Optional trailing `lang`: a language switch remounts the detail with a new key so HU/EN
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
@@ -134,6 +134,10 @@ export const qk = {
showId !== undefined
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
: (["plex-show"] as const),
// A show card's next episode. Its own root, not under `plex-show`: a watch write anywhere moves
// it, and the card reads it fresh (staleTime 0) instead of trusting every write site to invalidate.
plexShowNext: (showId: string, lang?: string) =>
["plex-show-next", showId, ...(lang ? [lang] : [])] as const,
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
// that steers the background extraction, not part of the track's identity.