Compare commits

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

Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
2026-10-08 03:09:55 +02:00
peter 75093458c9 feat(plex): subtitles rise above the control bar while it shows
On an iPad the bar covered the text whenever it was up: the browser places a
native cue by its line, which nothing animates. The track now stays loaded as
hidden and PlexSubtitles draws the active cues, measured against the bar and
lifted above it in 200 ms, back down when it hides. The viewer's size, colour,
background and outline settings style the text directly. Also fixes a test
typing slip from the search pill change.
2026-10-08 02:59:13 +02:00
peter 5db6dc8ee6 docs(design): log the touch header search 2026-10-08 02:52:39 +02:00
peter 45a9ceae25 fix(ui): the header search has room on a tablet
On touch the 44 px key cap and Go filled a 44 px pill edge to edge and the
placeholder was cut. The key cap is left out on touch (⌘K still works), the
pill grows to 52 px so a ring shows round Go, the box's floor counts the
controls beside the text, and Go drops to its icon by the column's width,
not the screen's.
2026-10-08 02:52:32 +02:00
peter 5249c41edc fix(player): a fullscreen button the browser cannot honour is not shown
Brave on iPadOS switches element fullscreen off, patches requestFullscreen with
a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button
and the YouTube docked bar's did nothing. Support is now read off
Element.prototype and the buttons are left out without it (owner). A stand-in
that returns a boolean no longer throws. A test's bare matchMedia stub no
longer outlives its describe.
2026-10-08 02:50:51 +02:00
peter c3664826fe fix(ui): a caption never opens under a finger
On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the
tap as a hover and swallowed its click, so every rail button needed two taps,
and with no mouseleave from a finger the caption stayed up. Tooltip now opens
for a mouse and a key's focus only (owner: no captions on touch).
2026-10-08 02:45:48 +02:00
peter c9b8fe15a3 fix(ui): a scroller with no edge to fade carries no mask
iPad WebKit kept a scrolled container's mask stuck to its content: at the
bottom of a scrolled page the last strip, a table's pager, sat under a dark
band. With nothing to fade there is no mask; isolation keeps its layer.
2026-10-08 02:43:58 +02:00
peter 1044eaf8a9 fix(plex): the player holds the page still and covers it whole; the row drops the countdown
On an iPad a drag on the video scrolled the page behind the player, and in Brave
dvh did not follow the toolbar, so a strip of the page showed under it. The
page is now locked without overscroll on a black root while the player is open,
and the player is sized from its fixed box. The time row keeps the position,
the length and the end clock; the countdown is gone (owner).
2026-10-08 02:42:31 +02:00
peter ca949d22de fix(player): the channel line wraps instead of squeezing the meta into a column
On a narrow card (a tablet, a zoomed window) the unwrapped row of name, meta and
actions left the meta a one-word column. The row now wraps: the meta keeps at
least 12rem, and the actions move down together.
2026-10-08 02:25:58 +02:00
peter 20178f0ec4 release: 0.102.0 (improvement/e37-player) 2026-10-08 01:58:01 +02:00
peter 49a9319f84 fix(player): the click layer clears only the fullscreen button's column, not a full-width band
Measured: across the full width YouTube needs only its seek bar (up to 96 px
from the bottom); the 140 px is the fullscreen button's, at the right end. A
full-width 140 px band handed clicks on the picture to the iframe, which then
takes the keyboard focus and our shortcuts with it. The layer is now two boxes,
hidden from assistive tech (a mouse surface; the keys are its shortcuts), and
the tests render the big view too, where the docked bar sits under the iframe.
2026-10-08 01:42:39 +02:00
peter 70d56ab939 fix(player): YouTube's fullscreen button stays clickable on a short player
The click layer over the embed left the bottom 22 % of the stage free. YouTube's
new embed keeps its fullscreen button 80-128 px above the iframe's bottom, so on
a stage under ~580 px (a small window, a browser zoomed to 150 %) the layer lay
over it and a click paused instead. The cutouts now have px floors (56 top, 140
bottom) and are measured against the iframe's own box.
2026-10-08 01:36:36 +02:00
peter 3865689c26 chore(release): 0.102.0 2026-10-08 01:33:39 +02:00
peter c32429ba69 fix(plex): an episode with no number is named by its title on every card
The cards, the up-next card and the mark-next label printed a question
mark where Plex has no number; the player's header already showed the
title alone. One helper (episodeNumbers/episodeName) for every place.
2026-10-08 01:16:30 +02:00
peter a504550196 docs: the superseded fallback answers its own session; who uses beacon() 2026-10-08 01:16:30 +02:00
peter 5095047c81 test(player): pin the stop URL's ids, the beacon's settle, the kept point's edges
The stop-URL contract passed with the two ids swapped and read the backend
from the working directory; nothing called a real beacon's onSettled or its
library pass-through; the kept-point rule was held by its constants only.
2026-10-08 01:16:30 +02:00
peter 4e70f168b1 fix(player): a layer that opens by itself takes no history entry
The auto-skip countdown and the up-next card pushed an entry without a
user gesture (Chrome's Back button skips those), and one pushed under the
open shortcuts sheet sat above it, so Back closed the hidden layer.
2026-10-08 01:16:30 +02:00
peter 7f17f488ab fix(nav): a sub-view step closes the overlays standing over it first
Leaving the Plex player while one of its layers was up (the info panel when
the film ended, the countdown under the back arrow) landed as a user Back:
the overlay listener spent it closing the top layer and the player stayed.
useHistorySubview's open/back now go through closeOverlaysThenNavigate,
like setPage. The RECLOSE comment no longer cites the shared Plex entry.
2026-10-08 01:16:30 +02:00
peter d1704a5519 revert: the Modal Escape guard for a fullscreen own-content video
Chrome does not hand the page the Escape it spends on leaving fullscreen (checked with a real
keypress: the first Esc leaves fullscreen, the second closes the player). The guard defended a case
that does not happen, under a comment saying it does. This reverts 121ebeeb.
2026-10-07 23:59:32 +02:00
peter 121ebeebc1 fix(ui): an Escape while a dialog's own video is fullscreen does not close the dialog
The page gets the Escape keydown with the video still the fullscreen element. When that element is
the dialog's own content (the library player), closing the dialog took the player away with the
fullscreen. A dialog opened over something else that is fullscreen still closes.
2026-10-07 23:57:04 +02:00
peter ee5290ba01 refactor(plex): the item progress body extends the shared checkpoint
ItemProgressIn repeated ProgressIn's config, fields and bounds; it now adds only `final`. The
threshold comment points where the constants live now, and plexProgressBeacon's comment is back
on it.
2026-10-07 23:16:38 +02:00
peter dd6a666ad1 test(player): hold the session stop and the kept-point rule to the server
- The client's resume thresholds equal the server's (read from both sources).
- The session stop's URL matches the route the server serves it on.
- The player stops its server session by its item and its own id on an unload and on close,
  and keeps it when the page only goes into the back/forward cache.
2026-10-07 23:16:38 +02:00
peter 392099770a fix(plex): an episode Plex has no number for is named by its title alone
The OS media title printed the missing number as "Enull", the player and info headers as a bare
"E". One helper for the three lines.
2026-10-07 23:16:38 +02:00
peter 2aa3ac6e85 fix(player): the browser Back peels each layer over the Plex video, one press at a time
All the layers shared one history entry, so the first Back spent it on one layer and the second
left the player with the stats readout (or the countdown) still up. Each layer now holds its own
entry; Back closes the one opened last.
2026-10-07 23:16:38 +02:00
peter 891dc628f3 fix(library): a listing poll can no longer put back an older resume point
A poll already out when the player closed answered with the point the server held before the
closing beacon, over the row's patch; Continue then opened there and wrote it back. The close now
cancels the listings' fetches before patching and reads them again once the beacon has landed.
2026-10-07 23:16:25 +02:00
peter 0a35f2a3b1 fix(player): a stale session answer can no longer stop the playback that won
- The engine tracks the request out per item: A, then B, then back to A sent a second request
  for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
  request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
  outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
  previous item under the next one's title.
2026-10-07 23:16:25 +02:00
peter d25a132495 fix(ui): a dialog whose own video goes fullscreen no longer crashes the app
The portal host followed the fullscreen element into it. When that element is the dialog's own
content (the library player's <video>, by its f key or its own button), the move is into the host's
own descendant, which the DOM refuses: HierarchyRequestError in a layout effect, and the crash
screen replaced the app. The host now stays where it is then — the content already shows.
2026-10-07 23:16:25 +02:00
peter 61d3a1a189 chore(db): drop plex_items.markers, a column nothing writes or reads
Markers are read live from Plex for the item the player opens; the cached copy was never filled.
2026-10-07 02:23:37 +02:00
peter d482a5e8e2 chore(dev): the dev proxy answers a failed connect with 502, as the prod proxy does
Vite's own answer was a bodiless 500, which the app reads as a real server fault and shows the blocking error dialog; a 502 is the passing connection loss it is.
2026-10-07 02:23:37 +02:00
peter 2064373ac2 fix(history): a step back never leaves the app for an older page
Chrome keeps 50 entries a tab, and when the list is full a new entry drops the
oldest one the page left without a user gesture, which can be the page's own
entry under a sub-view or an overlay. A programmatic back() then left the
document; once it landed on an older entry whose restored Plex player started
playing on its own. Every programmatic back() now asks the Navigation API first
and, with no entry of this document below, settles in place: the sub-view turns
to its root, an overlay's entry drops its mark, the channel page and the YouTube
search clear.
2026-10-07 02:23:37 +02:00
peter 0cda5b6dd4 fix(player): only the credits that end an item move on to the next one
An item can carry several credits blocks with mid- and post-credit scenes between
them, and Plex flags the one that really ends it. The player left at the first
block. Now the Plex-final block moves on (by the auto-skip or the Skip button), an
earlier block is only skipped to its end by the button, and with no final flag the
last block ends the item by the button alone.
2026-10-07 01:48:10 +02:00
peter f3c94641a7 fix(player): a library item played from the start keeps its point
A cached file that autoplays from 0:00 fires its one playing event before the resume hook
subscribes, so the hook never counted it as played and reported nothing - not on pause, not on
close. An element already playing when the hook subscribes now counts as played.
2026-10-07 00:36:28 +02:00
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
2026-10-06 23:55:41 +02:00
peter 02feaa9a1c refactor(player): one panel at a time over the Plex video, and one Back cascade
The ⋯ menu, the tracks menu, the tuning panel, the info overlay and the up-next card were a flag
each, so two could stand on top of each other, and Back/Escape were written out three times. They
are one `panel` value now; the stats readout stays a separate toggle beside it. One `back()` peels
the countdown, the panel, then the stats, then leaves — for the arrow, ⌫, Escape and the mouse
Back alike. The mouse Back now also closes the ⋯ menu instead of leaving the player.
2026-10-06 23:04:35 +02:00
peter c1058f1d5a refactor(player): the Plex session machine is a tested engine, one request per item in flight
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
2026-10-06 22:31:48 +02:00
113 changed files with 6383 additions and 1188 deletions
+22
View File
@@ -7,6 +7,28 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.103.0 — 2026-10-08
### New
- Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page.
### Fixed
- Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too.
- iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen.
- The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line.
## 0.102.0 — 2026-10-08
### New
- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped.
### Fixed
- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream.
## 0.101.0 — 2026-10-06
### New
+1
View File
@@ -102,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
+3 -2
View File
@@ -23,8 +23,9 @@ blocker and SponsorBlock keep working.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
- **In-app player** with resume, plus keyboard/scroll controls.
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
to your device, share with another user, or hand out a public watch link.
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
where you left off, then save to your device, share with another user, or hand out a public watch
link.
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
movies and shows alongside your feed, with watch state kept in sync.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
+1 -1
View File
@@ -1 +1 @@
0.101.0
0.103.0
-8
View File
@@ -743,14 +743,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 37,
"endColumn": 47,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -0,0 +1,42 @@
"""library: a resume point per user and library item
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
status: the library has no "watched". It starts empty; the downgrade drops it.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0099_library_states"
down_revision: str | None = "0098_drop_demo_account"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"library_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"job_id",
sa.Integer(),
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position_seconds", sa.Integer(), nullable=False),
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
)
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
def downgrade() -> None:
op.drop_index("ix_library_states_job_id", table_name="library_states")
op.drop_table("library_states")
@@ -0,0 +1,22 @@
"""plex_items: drop `markers`, a column nothing writes or reads
Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag
on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade
restores the empty column.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0100_drop_plex_item_markers"
down_revision: str | None = "0099_library_states"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.drop_column("plex_items", "markers")
def downgrade() -> None:
op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True))
+21 -2
View File
@@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LibraryState(Base):
"""Where a user stopped in a library item (their own download, or one shared with them) — the
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
__tablename__ = "library_states"
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
job_id: Mapped[int] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search.
@@ -1253,8 +1274,6 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
+7 -3
View File
@@ -167,12 +167,16 @@ class PlexClient:
keys = ",".join(str(k) for k in rating_keys)
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
def create_collection(
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
) -> dict:
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
uri = quote(self._metadata_uri(item_rating_key), safe="")
mc = self._write(
"POST",
f"/library/collections?type=1&smart=0&sectionId={section_key}"
f"/library/collections?type={item_type}&smart=0&sectionId={section_key}"
f"&title={quote(title, safe='')}&uri={uri}",
)
items = mc.get("Metadata", []) or []
+27 -8
View File
@@ -744,8 +744,9 @@ def start_session(
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
# registered session is no longer `replace_only`, we abort and return None, and the caller
# answers with its own dead session — never the newer one, whose id a stale requester would
# stop. The INITIAL spawn never gets None — see its call site.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
@@ -840,12 +841,13 @@ def start_session(
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests.
return current_session(user_id, key) or s
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
# Never None either: the POST /session route turns a None return into a "Media file not found
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
return s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready:
@@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
return dropped
def stop_session(user_id: int, key: str, session_id: str) -> bool:
"""Stop this viewer's session for `key` now, because the player that asked for it is gone (left
the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the
film first — measured: ~4 cores for a minute after leaving a resumed 480p film.
Only while `session_id` is still the registered one: the player names the session it played, and
a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's
current playback, not this request's to end."""
with _lock:
s = _sessions.get((user_id, key))
if s is None or s.id != session_id:
return False
_kill(s)
_sessions.pop((user_id, key), None)
return True
def touch_session(user_id: int, key: str) -> None:
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
+37 -1
View File
@@ -9,16 +9,52 @@ one existence-leak policy.
from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
from app.models import POSITION_MAX_S, User
from app.issues import gitea
from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base)
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
# library's): positions below this are "didn't really start" and within this of the end are
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by
# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin
# (30s) because it marks long-form items watched, not just clears a resume bar — see
# routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
class ProgressIn(BaseModel):
"""A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the
page goes away)."""
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (`kept_position`) — the player may post a raw currentTime (a float),
# which the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on
# the progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
def kept_position(body: ProgressIn) -> int:
"""The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to
count as started, or close enough to the end to count as finished)."""
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0
def not_found(what: str = "generic") -> HTTPException:
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
+68 -4
View File
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
import os
import re
import tempfile
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -17,7 +17,8 @@ from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
@@ -39,11 +40,19 @@ from app.models import (
DownloadProfile,
DownloadQuota,
DownloadShare,
LibraryState,
MediaAsset,
User,
display_text,
)
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
from app.routes._common import (
ProgressIn,
capped_stream_to,
get_or_404,
kept_position,
not_found,
owned_or_404,
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
@@ -165,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows}
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
ids = [j.id for j in jobs]
if not ids:
return {}
rows = db.execute(
select(LibraryState.job_id, LibraryState.position_seconds).where(
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
)
).all()
return {job_id: pos for job_id, pos in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
@@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
positions = _positions_for(db, user, jobs)
return [
{
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0),
}
for j in jobs
]
@router.get("/usage")
@@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
positions = _positions_for(db, user, jobs)
out = []
for j in jobs:
data = _serialize(j, assets.get(j.asset_id))
data["shared"] = True
data["position_seconds"] = positions.get(j.id, 0)
out.append(data)
return out
@@ -825,6 +856,39 @@ def download_file(
# --- editor filmstrip (scrub track) --------------------------------------------------------
@router.post("/{job_id}/progress")
def set_library_progress(
job_id: int,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
one shared with them). A position too early or too close to the end clears it instead."""
_accessible_job(db, user, job_id)
position = kept_position(body)
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
if not position:
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
# one stop may both clear it, and the second then deletes nothing rather than failing.
db.execute(delete(LibraryState).where(mine))
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": 0}
now = datetime.now(timezone.utc)
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
# read-then-insert that the second would fail on the unique key.
db.execute(
pg_insert(LibraryState)
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
.on_conflict_do_update(
constraint="uq_library_user_job",
set_={"position_seconds": position, "progress_updated_at": now},
)
)
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": position}
@router.get("/{job_id}/storyboard")
def storyboard_meta(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+11 -34
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -26,14 +26,19 @@ from app.feedquery import (
windowed_relevance_rows,
)
from app.models import (
POSITION_MAX_S,
ChannelTag,
Tag,
User,
Video,
VideoState,
)
from app.routes._common import get_or_404, youtube_refusal, not_found
from app.routes._common import (
ProgressIn,
get_or_404,
kept_position,
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"}
# Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth
# storing, so we clear the position instead (the video shows no progress bar).
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
@@ -326,23 +323,10 @@ def clear_video_state(
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
class VideoProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
@router.post("/videos/{video_id}/progress")
def set_video_progress(
video_id: str,
body: VideoProgressIn,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -350,22 +334,15 @@ def set_video_progress(
Stores a per-user position on the video_states row without touching watch status, so a
partially-watched video can render a progress bar and match the "in progress" filter.
Trivially-early and near-finished positions clear the position rather than store it."""
position = int(body.position_seconds)
duration = int(body.duration_seconds)
if position < 0:
position = 0
position = kept_position(body)
get_or_404(db, Video, video_id, what="video")
# Decide whether this position is worth keeping (else clear it).
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
keep = position >= PROGRESS_MIN_SECONDS and not near_end
row = db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
).scalar_one_or_none()
if not keep:
if not position:
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
# zero the position (a saved/hidden video keeps its status).
if row is not None:
+57 -3
View File
@@ -176,6 +176,40 @@ def collections(
}
@router.get("/collections/for/{item_rating_key}")
def collections_for_item(
item_rating_key: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
library (a collection lives in one library, and only a manual one can take a title — the same
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
movie = by_rating_key(db, PlexItem, item_rating_key)
title: PlexItem | PlexShow | None = (
movie if movie is not None and movie.kind == "movie" else None
) or by_rating_key(db, PlexShow, item_rating_key)
if title is None:
raise not_found("item")
member_of = set(title.collection_keys or [])
rows = (
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == title.library_id)
.order_by(collated(PlexCollection.title, lang_for_user(user)))
)
.scalars()
.all()
)
return {
"collections": [
{**_collection_card(c), "contains": c.rating_key in member_of}
for c in rows
if _collection_editable(c)
]
}
class Member(NamedTuple):
"""One collection member flattened to everything the Sort control can order by. The page's two
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
@@ -407,27 +441,47 @@ def create_collection(
seed = body.item_rating_key
if not title or not seed:
raise refused(400, "collection_fields_required")
seed_show = by_rating_key(db, PlexShow, seed)
if library:
lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
)
else:
# The "New collection" flow only knows the seed title → derive its library.
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
if lib is None:
raise not_found("library")
_reject_duplicate_name(db, lib.id, title)
wrote = False
new_rk = ""
try:
with PlexClient(db) as plex:
new = plex.create_collection(lib.plex_key, title, seed)
# A show collection is made as one (Plex type 2): as a movie one it stayed empty.
new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1)
wrote = True
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
new_rk = str(new.get("ratingKey") or "")
col = plex_sync.resync_collection(db, plex, lib, new_rk)
col.editable = True
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
if wrote and new_rk:
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
# full sync preserves `editable` but creates a missing row read-only, so the collection
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
# the collection it was just added to, and the collection empty, until that sync.
db.add(
PlexCollection(
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
db.commit()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
+4
View File
@@ -404,6 +404,10 @@ def item_detail(
"type": m.get("type"),
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
# An item can carry several credits blocks with scenes between them; Plex
# flags the one that really ends it. Which credits may leave the item is the
# player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it.
"final": bool(m.get("final")),
}
)
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
+24 -17
View File
@@ -15,7 +15,7 @@ from fastapi import (
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -23,7 +23,6 @@ from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db
from app.models import (
POSITION_MAX_S,
PlexItem,
PlexSeason,
PlexShow,
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import not_found
from app.routes._common import ProgressIn, not_found
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -160,11 +159,11 @@ def subtitle(
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
# Resume/watched thresholds. _PROGRESS_MIN_S matches the feed's PROGRESS_MIN_SECONDS (5), but
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
# not drift. Keep them separate on purpose (see routes/feed.py).
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
# Keep them separate on purpose (see routes/_common.py).
_PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
return False
class ItemProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
class ItemProgressIn(ProgressIn):
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
final: bool = False
@@ -637,12 +629,27 @@ def stream_session(
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
# names resolve back into the same session — see `stream_hls_file` for why it has to.
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
# What the player names when it no longer wants the session (POST …/session/{id}/stop).
"id": s.id,
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
}
@router.post("/stream/{rating_key}/session/{session_id}/stop")
def stream_session_stop(
rating_key: str,
session_id: str,
user: User = Depends(current_user),
) -> dict:
"""The player no longer wants this session (it left the item, closed, or the page unloaded):
its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a
keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and
only while it is still the one registered for the item (see `stop_session`)."""
return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)}
@router.get("/stream/{rating_key}/file")
def stream_file(
rating_key: str,
+135
View File
@@ -0,0 +1,135 @@
"""The library player's resume point (`library_states`).
A checkpoint stores where the caller stopped in a library item they can play — their own download,
or one shared with them — and the two library listings hand it back per item, so the player opens
there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller
cannot reach, is ever read or written. DB-backed (real Postgres).
"""
import pytest
from sqlalchemy import select
from app.models import DownloadJob, DownloadShare, LibraryState
from app.routes._common import ProgressIn, kept_position
def _done_job(db, user) -> DownloadJob:
job = DownloadJob(
user_id=user.id,
source_kind="url",
source_ref="https://example.com/a.mp4",
status="done",
profile_snapshot={},
)
db.add(job)
db.commit()
return job
def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int:
"""Send a checkpoint the caller may make; the point the server kept (0 = cleared)."""
r = client.post(
f"/api/downloads/{job_id}/progress",
json={"position_seconds": position, "duration_seconds": duration},
)
assert r.status_code == 200, r.text
return r.json()["position_seconds"]
def _states(db) -> list[tuple[int, int, int]]:
db.expire_all()
rows = db.execute(
select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds)
).all()
return [tuple(r) for r in rows]
def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
r = c.post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 754.9, "duration_seconds": 3000},
)
assert r.status_code == 200 and r.json() == {
"ok": True,
"job_id": job.id,
"position_seconds": 754,
}
[item] = c.get("/api/downloads").json()
assert item["position_seconds"] == 754
# A second checkpoint moves it rather than adding a row.
assert _checkpoint(c, job.id, 900) == 900
assert _states(db) == [(me.id, job.id, 900)]
def test_an_item_never_played_lists_zero(db, make_user, as_user):
me = make_user()
_done_job(db, me)
[item] = as_user(me).get("/api/downloads").json()
assert item["position_seconds"] == 0
def test_too_early_or_near_the_end_clears_it(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 2995) == 0
assert _states(db) == []
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 3) == 0
assert _states(db) == []
# Clearing what is not there is not an error (the beacon and the last checkpoint of one stop).
assert _checkpoint(c, job.id, 0) == 0
# The edges themselves, one second either side — the same rows as the client's
# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared
# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side.
@pytest.mark.parametrize(
("position", "duration", "kept"),
[(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)],
)
def test_the_kept_point_at_its_exact_edges(position, duration, kept):
body = ProgressIn(position_seconds=position, duration_seconds=duration)
assert kept_position(body) == kept
def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user):
owner, friend = make_user(), make_user()
job = _done_job(db, owner)
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
db.commit()
assert _checkpoint(as_user(owner), job.id, 100) == 100
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
owner, stranger = make_user(), make_user()
job = _done_job(db, owner)
r = as_user(stranger).post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 600, "duration_seconds": 3000},
)
assert r.status_code == 404
assert _states(db) == []
def test_deleting_the_job_deletes_its_places(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
assert _checkpoint(as_user(me), job.id, 600) == 600
db.delete(db.get(DownloadJob, job.id))
db.commit()
assert _states(db) == []
+4
View File
@@ -159,6 +159,10 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex):
assert plex.seen[-1].method == "POST"
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
assert params["type"] == "1" # a movie collection unless told otherwise
# A show collection is made as one: made as a movie one, it came out empty in a TV section.
plex.create_collection("3", "Shows", "42", 2)
assert plex.seen[-1].url.params["type"] == "2"
plex.routes["/library/collections"] = {}
with pytest.raises(PlexError):
+33 -1
View File
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
@@ -14,6 +15,7 @@ from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
from app.plex.client import PlexError
from app.plex.lookups import by_rating_key
from app.routes.plex import collections as coll_routes
@@ -224,8 +226,9 @@ class _CollStub:
if self.fail_on == name:
raise PlexError("boom")
def create_collection(self, section_key, title, item_rating_key):
def create_collection(self, section_key, title, item_rating_key, item_type=1):
self._maybe("create_collection")
self.created_type = item_type
return {"ratingKey": "NEW"}
def add_collection_item(self, rk, item):
@@ -320,6 +323,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
assert body["editable"] is True # create marks the new collection editable
@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)])
def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type):
# Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out
# empty with a show seed, and refused the show when it was added after.
lib, user = _seed_editable(db)
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series"))
db.commit()
stub = _CollStub()
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
r = client.post(
"/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed}
)
assert r.status_code == 200, r.text
assert stub.created_type == plex_type
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
# the honest "reconcile on the next sync" detail, not a false "write failed".
@@ -337,6 +356,19 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
assert r.status_code == 422, r.text
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "create_collection" in stub.calls
# It exists on Plex: its row stays, editable, until the next sync fills it in — so it is offered
# where collections are added to, and the same name is not made twice.
row = by_rating_key(db, PlexCollection, "NEW")
assert row is not None and row.editable and row.title == "Fresh Coll"
# Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it.
assert row.child_count == 1
assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or [])
again = client.post(
"/api/plex/collections",
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
)
assert again.status_code == 409, again.text
assert stub.calls.count("create_collection") == 1
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
@@ -0,0 +1,86 @@
"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the
title is in it already. DB lane (`db` fixture)."""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
def _seed(db):
movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movies, shows])
db.flush()
enriched = datetime(2024, 1, 1, tzinfo=timezone.utc)
db.add_all(
[
PlexItem(
rating_key="m1",
title="Film",
kind="movie",
library_id=movies.id,
collection_keys=["cA"],
people_enriched_at=enriched,
),
PlexItem(
rating_key="e1",
title="An episode",
kind="episode",
library_id=shows.id,
people_enriched_at=enriched,
),
PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]),
# The movie library's: two manual ones it can go into, and three it cannot.
PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True),
PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True),
PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False),
PlexCollection(
rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True
),
PlexCollection(
rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True
),
# The show library's own.
PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True),
]
)
user = User(email="collfor@example.com")
db.add(user)
db.commit()
return user
def _get(db, monkeypatch, override_dep, rk):
user = _seed(db)
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app).get(f"/api/plex/collections/for/{rk}")
def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it(
db, monkeypatch, override_dep
):
resp = _get(db, monkeypatch, override_dep, "m1")
assert resp.status_code == 200
got = [(c["id"], c["contains"]) for c in resp.json()["collections"]]
assert got == [("cA", True), ("cB", False)]
def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep):
resp = _get(db, monkeypatch, override_dep, "s1")
assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)]
def test_an_episode_is_not_a_collection_member(db, monkeypatch, override_dep):
assert _get(db, monkeypatch, override_dep, "e1").status_code == 404
def test_an_unknown_key_is_not_found(db, monkeypatch, override_dep):
assert _get(db, monkeypatch, override_dep, "nope").status_code == 404
+35
View File
@@ -401,3 +401,38 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title(
("magyar Forced", "hu", True, False, None),
("English SDH", "en", False, True, None),
]
def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep):
"""An item can carry several credits markers — mid- and post-credit scenes sit between them —
and only the one Plex flags `final` is the real end; the player needs that flag to know which
credits may move on to the next item."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man"))
db.commit()
user = _user(db, "markers@example.com")
db.commit()
meta = {
"Marker": [
{"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000},
{"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000},
{
"type": "credits",
"startTimeOffset": 7549000,
"endTimeOffset": 7559000,
"final": True,
},
{"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2},
]
}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2")
assert resp.status_code == 200, resp.text
assert resp.json()["markers"] == [
{"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False},
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
]
@@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
assert body["mode"] == "hls"
assert body["transcode"] is False
assert captured["transcode"] is False
# The id the player names when it stops the session (POST …/session/{id}/stop).
assert body["id"] == "sid"
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
+101
View File
@@ -0,0 +1,101 @@
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
played; the server stops it only while it is still that viewer's registered session for the item, so
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
"""
import time
import pytest
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.main import app
from app.models import User
from app.plex import stream as plex_stream
VIEWER = 41
class _Proc:
def __init__(self):
self.terminated = False
def poll(self):
return None
def terminate(self):
self.terminated = True
def wait(self, timeout=None):
return 0
class _Session:
def __init__(self, session_id: str, directory):
self.id = session_id
self.dir = directory
self.proc = _Proc()
self.last_access = time.time()
@pytest.fixture
def sessions(monkeypatch):
live: dict = {}
monkeypatch.setattr(plex_stream, "_sessions", live)
return live
@pytest.fixture
def client(monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True)
user = User(id=VIEWER, email="stop@example.com")
override_dep(current_user, lambda: user)
return TestClient(app)
def _session(tmp_path, sid: str) -> _Session:
d = tmp_path / sid
d.mkdir()
return _Session(sid, d)
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
s = _session(tmp_path, "sid-a")
sessions[(VIEWER, "m1")] = s
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": True}
assert s.proc.terminated
assert sessions == {}
assert not s.dir.exists()
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
newer = _session(tmp_path, "sid-b")
sessions[(VIEWER, "m1")] = newer
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not newer.proc.terminated
assert sessions == {(VIEWER, "m1"): newer}
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
theirs = _session(tmp_path, "sid-a")
sessions[(7, "m1")] = theirs
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not theirs.proc.terminated
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": False}
+7 -2
View File
@@ -695,7 +695,9 @@ class TestTranscodeStartSession:
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
# The identity guard must detect it's been superseded and yield the currently-registered session.
# The identity guard must detect it's been superseded. It answers with its OWN (dead) session,
# never the newer one: the answer goes to the request that lost, whose player drops it as stale
# and stops the session it names — naming the newer one stopped the playback that won.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
newer = _Newer()
captured = {}
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
captured["s"] = stream._sessions[(7, "12345")]
stream._sessions[(7, "12345")] = newer
return False
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
)
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert s is captured["s"] # its own session, not the newer one
assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
+8 -2
View File
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
return TestClient(app)
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
def test_a_non_finite_position_is_refused(client, path, raw):
r = client.post(
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
assert r.status_code == 422, r.text
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
+4 -4
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn
from app.routes.feed import VideoProgressIn
from app.routes._common import ProgressIn
from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path.
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError):
VideoProgressIn(position_seconds="not-a-number")
ProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
+11 -2
View File
@@ -528,7 +528,9 @@ of a full-width panel and nothing on the right.
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
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]
@@ -541,7 +543,9 @@ of a full-width panel and nothing on the right.
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG]
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
focused ✕ closed the film. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
@@ -706,3 +710,8 @@ of a full-width panel and nothing on the right.
| 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" |
+49 -77
View File
@@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListPlus, Plus } from "lucide-react";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { MembershipMenu } from "./ui/MembershipMenu";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
export default function AddToPlaylist({
videoId,
variant = "ghost",
@@ -94,9 +90,7 @@ function MembershipPanel({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
@@ -115,13 +109,17 @@ function MembershipPanel({
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that
// landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it.
async function toggle(pl: Playlist) {
if (busy) return;
setBusy(true);
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
} catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
}
@@ -129,19 +127,20 @@ function MembershipPanel({
setBusy(false);
}
async function createAndAdd() {
const name = newName.trim();
if (!name || busy) return;
async function createAndAdd(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
setNewName("");
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
}
refresh();
setBusy(false);
return made;
}
// All of the user's playlists are add targets, including YouTube-linked ones — adding
@@ -149,67 +148,40 @@ function MembershipPanel({
const lists = membership.data ?? [];
return (
<>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fadeRef}
style={fadeStyle}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss; every other key is text editing here and
// must not reach the surrounding menu's list navigation.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void createAndAdd();
}
}}
placeholder={t("playlists.newPlaylist")}
/>
</div>
</div>
</PopoverSurface>
</>
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-60 p-2`}
ariaLabel={t("playlists.addToPlaylistTitle")}
listInside
>
<MembershipMenu
heading={t("playlists.addToPlaylistTitle")}
rows={lists.map((pl) => ({
id: pl.id,
label: playlistName(pl, t),
checked: !!pl.has_video,
extra:
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
) : undefined,
}))}
onToggle={(row) => {
const pl = lists.find((p) => p.id === row.id);
if (pl) void toggle(pl);
}}
status={
membership.isError && !membership.data
? "error"
: membership.isLoading
? "loading"
: "ready"
}
onRetry={() => void membership.refetch()}
emptyText={t("playlists.noneYet")}
errorText={t("common.loadError")}
busy={busy}
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
/>
</PopoverSurface>
);
}
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
//
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import { ChannelTagsControl } from "./ChannelTags";
import { api } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the channel tag editor", () => {
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<ChannelTagsControl
userTags={[]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
expect(await screen.findByText("common.retry")).toBeTruthy();
});
});
+26 -70
View File
@@ -1,17 +1,15 @@
import { useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
import { MembershipMenu } from "./ui/MembershipMenu";
import { useTags } from "../lib/useTags";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
@@ -194,8 +192,9 @@ function SuggestedChipsLane({
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
function TagEditorMenu({
userTags,
attachedIds,
@@ -208,71 +207,27 @@ function TagEditorMenu({
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const tags = useTags();
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
if (!name) return;
onCreate(name);
setDraft("");
};
return (
<div className="flex flex-col">
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<PopoverItem
key={tg.id}
// Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
})}
{userTags.length === 0 && (
<p className="text-2xs text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
// list-navigation/typeahead handlers on the surrounding surface.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
/>
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
label={t("channels.tags.create")}
>
<Plus />
</IconButton>
</div>
</div>
<MembershipMenu
dense
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
onToggle={(row) => onToggle(row.id as number)}
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
onRetry={() => void tags.refetch()}
emptyText={t("channels.tags.noneYet")}
errorText={t("common.loadError")}
create={{
placeholder: t("channels.tags.createPlaceholder"),
maxLength: 64,
submitLabel: t("channels.tags.create"),
onCreate: (name) => {
onCreate(name);
return true;
},
}}
/>
);
}
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
<PopoverSurface
popover={editPop}
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
listInside
>
<TagEditorMenu
userTags={userTags}
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
]);
});
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
+16 -4
View File
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
// What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
{playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
+1 -1
View File
@@ -199,7 +199,7 @@ function useHomeCardMenu({
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
@@ -5,7 +5,9 @@
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render as rtlRender, cleanup } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const baseJob: DownloadJob = {
id: 7,
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
},
});
afterEach(cleanup);
// The player writes its resume point into the library listings' cache, so it sits under a client.
const render = (ui: ReactElement, client = new QueryClient()) =>
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
function loaded(duration = 600) {
const video = document.body.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
@@ -130,4 +153,81 @@ describe("LibraryPlayer", () => {
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.parentElement;
});
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
const panel = document.activeElement as HTMLElement;
expect(panel.tagName).toBe("DIV");
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
});
it("opens where the viewer stopped — or from the start when asked", () => {
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
render(<LibraryPlayer job={job} onClose={() => {}} />);
expect(loaded().currentTime).toBe(300);
cleanup();
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
position_seconds: 125,
});
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 125.4;
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
});
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
// The listing polls. One already out when the player closed answered with the point the server
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
// first checkpoint wrote that older point back over the right one.
let settled: (() => void) | undefined;
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
settled = onSettled;
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 300;
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
});
});
+58 -8
View File
@@ -1,10 +1,15 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { keptPosition, useResume } from "../lib/resume";
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
// any codec (best-effort playback).
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
//
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
// controls and the basic keys drive it like the other players.
export default function LibraryPlayer({
job,
fromStart = false,
onClose,
}: {
job: DownloadJob;
fromStart?: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
@@ -32,16 +50,48 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const mediaRef = useCallback(
(el: HTMLMediaElement | null) => {
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
logMedia(el);
setMedia(el);
},
[logMedia],
);
const title = jobTitle(job);
useMediaSession(media, {
title,
artist: job.display_uploader ?? asset?.uploader ?? null,
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch. On the way out a listing poll may already be out, having read the
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
// one. Once the point is on the server, the listings are read again.
const lists = [qk.downloads(), qk.downloadsShared()];
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of lists) {
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
}
if (leaving) {
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
});
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
return (
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
@@ -51,13 +101,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={videoRef}
ref={mediaRef}
src={fileUrl}
poster={poster}
controls
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// The provider reads the signed-in account (the tab title) from the `me` cache.
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -206,6 +220,31 @@ describe("NavigationProvider · Back", () => {
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
+15 -7
View File
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
}
}, []);
@@ -143,9 +146,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
window.history.replaceState(st, "");
}
}, []);
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
+3 -2
View File
@@ -28,7 +28,8 @@ function useCap(): string {
// words matched nothing.
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
* too; left out of a phone's narrow header, where there is no keyboard to press it on. */
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
export function PaletteKeycap() {
const { t } = useTranslation();
const cap = useCap();
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
>
{cap}
</Pressable>
@@ -444,6 +444,72 @@ describe("PlayerModal — the side arrows", () => {
});
});
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
// top. A cutout given only as a share of the height lay over that button on a short player (a small
// window, a browser zoomed to 150 %): the click paused instead.
describe("PlayerModal — the click layer over the video", () => {
const layers = () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
return screen.getAllByTestId("yt-click-layer");
};
const box = (el: HTMLElement) => {
const { left, right, width, top, bottom } = el.style;
return { left, right, width, top, bottom };
};
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
const [picture, column] = layers();
expect(box(picture!)).toEqual({
left: "0px",
right: "88px",
width: "",
top: "max(12%, 56px)",
bottom: "max(22%, 104px)",
});
expect(box(column!)).toEqual({
left: "",
right: "0px",
width: "88px",
top: "max(12%, 56px)",
bottom: "max(22%, 140px)",
});
});
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
// bottom, so a box measured from the stage's would start that bar's height too low again.
it.each([
["the windowed card", false, /(^|\s)relative(\s|$)/],
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
const els = layers();
const stage = document.querySelector(".player-stage")!;
if (fullscreen) {
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
get: () => stage,
});
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
}
try {
for (const el of screen.getAllByTestId("yt-click-layer")) {
const wrapper = el.parentElement!;
expect(wrapper).not.toBe(stage);
expect(wrapper.parentElement).toBe(stage);
expect(wrapper.className).toMatch(inset);
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
}
expect(els).toHaveLength(2);
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
@@ -485,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
// every later test's media query would read that bare stub.
let added = false;
beforeEach(() => {
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
if (typeof window.matchMedia === "function") return;
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
added = true;
});
afterEach(() => {
// The spy goes first: restoring it later would put the bare stub back.
vi.restoreAllMocks();
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
added = false;
});
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
@@ -591,3 +668,40 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(screen.queryByTestId("player-description")).toBeNull();
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
describe("PlayerModal — the docked bar's fullscreen", () => {
const maximised = (api_: boolean) => {
if (api_)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
else
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length;
};
afterEach(() => {
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
it("is offered where the browser can fullscreen an element", () => {
expect(maximised(true)).toBe(2);
});
it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1);
});
});
+128 -81
View File
@@ -6,6 +6,7 @@ import {
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
@@ -56,7 +57,7 @@ import {
formatCompact,
relativeTime,
} from "../lib/format";
import { fullscreenElement } from "../lib/fullscreen";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
@@ -106,6 +107,35 @@ const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the
// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button
// above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never
// shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the
// layer lies over that button and a click on it pauses instead. Only the right-hand column needs the
// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the
// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any
// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap.
// Px, not rem: the text-size slider does not grow YouTube's controls.
const YT_TOP_CONTROLS_PX = 56;
const YT_SEEK_BAR_PX = 104;
const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 };
// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the
// column above that button.
const YT_CLICK_LAYER: CSSProperties[] = [
{
left: 0,
right: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`,
},
{
right: 0,
width: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`,
},
];
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
@@ -115,6 +145,9 @@ const metaSep = (
</span>
);
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
// Beside the channel name the meta takes what is left of the line, but never less than this: under it
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
const META_MIN = "min-w-48 flex-1";
export default function PlayerModal({
video,
@@ -689,39 +722,48 @@ export default function PlayerModal({
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
measured against the iframe it sits in (in the big views that excludes our docked bar)
and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden
from assistive tech: the keyboard has the shortcuts it stands for.
Armed in the big views too (that is what keeps wheel volume working after F, the reported
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null &&
YT_CLICK_LAYER.map((box, i) => (
<div
key={i}
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-hidden="true"
style={box}
className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
))}
</div>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
Armed in the big views too (that is what keeps wheel volume working after F, the reported
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
@@ -771,13 +813,18 @@ export default function PlayerModal({
</Button>
)}
<span className="h-4 w-px bg-white/20" />
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
@@ -922,10 +969,13 @@ export default function PlayerModal({
</Button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
{/* Channel + meta on one line, with the actions pushed to the right.
When navigated to a linked video we only have its author (from the player),
so we show that as plain text and hide feed-video-specific bits. */}
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
so we show that as plain text and hide feed-video-specific bits.
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
one-word column between the name and the buttons. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
@@ -973,7 +1023,7 @@ export default function PlayerModal({
)}
{!navigated ? (
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
<span>
@@ -1004,7 +1054,7 @@ export default function PlayerModal({
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{detail.data?.view_count != null && (
<span>
@@ -1037,51 +1087,48 @@ export default function PlayerModal({
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
// Narrow: the actions end the channel line. Wide: their own line in the panel.
className={wide ? undefined : "ml-auto"}
>
<Maximize />
</IconButton>
)}
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
)}
{!navigated && onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
{!navigated && (
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
)}
</div>
{wide && (
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
}));
import PlexPlaylistView from "./PlexPlaylistView";
import AddToPlaylist from "./AddToPlaylist";
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import TagManager from "./TagManager";
import { api, HttpError, type PlexCard } from "../lib/api";
@@ -157,6 +157,64 @@ describe("the add-to-playlist popover", () => {
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("playlists.noneYet")).toBeNull();
});
it("opened from a card's menu, is a menu named for itself, its lines beside the list", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
const anchor = document.createElement("button");
document.body.append(anchor);
render(
<QueryClientProvider client={client()}>
<AddToPlaylistAt videoId="v1" anchor={anchor} onClose={() => {}} />
</QueryClientProvider>,
);
// The menu row that opened it is gone: its own name, not a label pointing at that row. jsdom
// computes no name for the unpositioned surface, so read what names it.
const menu = await waitFor(() => {
const found = document.querySelector(
'[role="menu"][aria-label="playlists.addToPlaylistTitle"]',
);
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.closest("[role=menu]")).toBeNull();
anchor.remove();
});
it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => {
const lists = vi.spyOn(api, "playlists");
lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never);
// The refetch after the write never answers: the tick must not wait for it.
lists.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never);
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never);
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const row = await screen.findByRole("menuitemcheckbox", { hidden: true });
fireEvent.click(row);
await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true"));
fireEvent.click(row);
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
@@ -0,0 +1,139 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, HttpError, type PlexCollection } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { MembershipMenu } from "./ui/MembershipMenu";
import { SURFACE } from "./ui/surfaces";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
// collection, its name, a search for the title, then another search to add the next one. Its body is
// the same MembershipMenu as the video card's "Add to playlist".
export interface PlexCollectionTarget {
ratingKey: string;
anchor: Element;
}
export default function PlexCollectionAddAt({
target,
onClose,
}: {
target: PlexCollectionTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
offset: 6,
hostsInput: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
const [busy, setBusy] = useState(false);
const { ratingKey } = target;
const forKey = qk.plexCollections("for", ratingKey);
const list = useQuery({
queryKey: forKey,
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
// all show membership.
function refresh() {
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
// before its refetch returned would otherwise repeat the write instead of undoing it.
async function toggle(c: PlexCollection & { contains: boolean }) {
if (busy) return;
setBusy(true);
try {
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
async function create(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
made = true;
} catch (e) {
made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
return made;
}
const collections = list.data?.collections ?? [];
return (
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-64 p-2`}
ariaLabel={t("plex.collectionAdd.title")}
listInside
>
<MembershipMenu
heading={t("plex.collectionAdd.title")}
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
onToggle={(row) => {
const c = collections.find((x) => x.id === row.id);
if (c) void toggle(c);
}}
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
onRetry={() => void list.refetch()}
emptyText={t("plex.collectionAdd.none")}
errorText={t("plex.collectionAdd.loadFailed")}
busy={busy}
create={{
placeholder: t("plex.collection.newCollection"),
ariaLabel: t("plex.collection.namePlaceholder"),
onCreate: create,
}}
/>
</PopoverSurface>
);
}
/** A create refused only because the read after it failed: the collection is on Plex, with this
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
function madeOnPlex(e: unknown): boolean {
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
return d?.reason === "collection_pending_sync";
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
old: T | undefined,
id: string,
): T | undefined {
if (!old) return old;
return {
...old,
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
};
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
DETAIL_POSTER,
episodeLine,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
@@ -315,7 +316,7 @@ export default function PlexInfo({
)}
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
{episodeLine(detail)}
</p>
)}
</div>
+197 -4
View File
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PlexCard } from "../lib/api";
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -101,21 +102,27 @@ function Grid({
);
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
function setup(
c: PlexCard,
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
{ admin = false }: { admin?: boolean } = {},
) {
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={qc}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
return { handlers, qc };
}
// The card root holds the menu's trigger props (right-click anywhere on it).
const rightClick = () =>
@@ -223,3 +230,189 @@ describe("a Plex title's menu", () => {
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
});
});
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
// search for the title. From the card's menu it is a tick, or a name and Enter.
describe("a Plex title's menu — Add to collection", () => {
const coll = (id: string, title: string, contains: boolean) =>
({ id, title, contains }) as PlexCollection & { contains: boolean };
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
setup(card({}), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "show" }), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "episode" }), {}, { admin: true });
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
cleanup();
setup(card({}));
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
});
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
});
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const row = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
fireEvent.click(await row("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
// One write at a time: the next tick waits for this one to settle.
await vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
fireEvent.click(await row("Alpha"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
const name = screen.getByLabelText("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: " Rainy days " } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
});
const checkRow = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
const idle = () =>
vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
const list = vi.spyOn(api, "plexCollectionsFor");
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
// The refetch after the write never answers: the tick must not wait for it.
list.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
await idle();
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("refreshes a show's own page too, whose collections come with the show", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cB", "Beta", false)],
});
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
const invalidate = vi.spyOn(qc, "invalidateQueries");
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await idle();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
});
it("is a named menu that says it is loading, not that there are no collections", async () => {
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
const menu = await vi.waitFor(() => {
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
expect(screen.getByText("common.loading")).toBeTruthy();
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
});
it("says a failed load as one, with a retry — not as no collections", async () => {
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
// A menu owns its items only: the line about the list sits beside it, not in it.
expect(document.querySelector("[role=menu]")).not.toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
list.mockResolvedValue({
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
});
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!;
retry.focus();
fireEvent.click(retry);
const beta = await checkRow("Beta");
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
// …and the menu's own arrows carry on from there.
fireEvent.keyDown(beta, { key: "ArrowDown" });
expect(document.activeElement).toBe(await checkRow("Gamma"));
});
it("clears the name when Plex made the collection and only the read after it failed", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
const create = vi
.spyOn(api, "plexCreateCollection")
.mockRejectedValue(
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: "Noir" } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
// It exists now: a second Enter on the kept name could only be refused as taken.
await vi.waitFor(() => expect(name.value).toBe(""));
});
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
() => new Promise((resolve) => (answer = resolve)),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
name.focus();
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
const alpha = await checkRow("Alpha");
fireEvent.keyDown(name, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
name.focus();
fireEvent.keyDown(name, { key: "ArrowUp" });
expect(document.activeElement).toBe(await checkRow("Beta"));
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ export function PlexMenu({
<ContextMenu
menu={menu}
ariaLabel={t("plex.menu")}
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
/>
{actions.panel}
</>
@@ -358,7 +358,7 @@ describe("keyboard focus in the bands", () => {
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
const intro = {
...item("A"),
markers: [{ type: "intro", start_s: 50, end_s: 200 }],
markers: [{ type: "intro", start_s: 50, end_s: 200, final: false }],
} as unknown as PlexItemDetail;
mount({ details: { A: intro } });
await playingAt("/media/A", 100);
@@ -857,3 +857,73 @@ describe("the seek bar (C3)", () => {
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
});
});
describe("the time in the row", () => {
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
expect(row.textContent).not.toMatch(/-\s*48:20/);
expect(row.textContent).toMatch(/\(.+\)/);
});
});
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
// own background under the player.
describe("the page under the player", () => {
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
const root = document.documentElement.style;
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(document.body.style.overflow).toBe("hidden");
expect(root.overscrollBehavior).toBe("none");
expect(root.backgroundColor).toBe("black");
cleanup();
expect(document.body.style.overflow).toBe("");
expect(root.overscrollBehavior).toBe("");
expect(root.backgroundColor).toBe("");
});
it("is covered by a player sized from its fixed box, not from dvh", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
// nothing there. It is shown only where the browser has the API on Element.prototype.
describe("the fullscreen button", () => {
const withApi = (on: boolean) => {
if (on)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
};
afterEach(() => withApi(false));
it("is there where the browser can fullscreen an element", async () => {
withApi(true);
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
});
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
withApi(false);
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
});
@@ -0,0 +1,183 @@
// @vitest-environment jsdom
//
// What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks
// menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces
// the other, so two can never draw on top of each other. The stats readout is not one of them — it
// stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
// leave the player; the mouse/browser Back peels them one per press too, the one opened last first.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act, screen, fireEvent } 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" } }),
}));
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } 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;
for (const [name, value] of Object.entries({
play: () => Promise.resolve(),
pause: () => {},
load: () => {},
}))
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const detail = {
id: "1",
title: "Title 1",
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: 0,
audio_streams: [],
subtitle_streams: [],
markers: [],
cast: [],
genres: [],
} as unknown as PlexItemDetail;
async function mount() {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexSession").mockResolvedValue({
mode: "direct",
url: "/media/1",
start_s: 0,
} as Awaited<ReturnType<typeof api.plexSession>>);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const onClose = vi.fn();
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={onClose} />
</QueryClientProvider>,
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
);
return onClose;
}
/** The browser's Back, waited out to its popstate. */
const browserBack = () =>
act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
const press = (key: string) =>
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
});
// `hidden`: the faded-out control bands are aria-hidden while the video plays.
const settings = () => screen.queryByRole("dialog", { name: "plex.player.settings", hidden: true });
const stats = () => screen.queryByRole("region", { name: "plex.player.stats.title", hidden: true });
const moreMenu = () => screen.queryByRole("menu", { hidden: true });
// The info overlay is the full-screen scrim the media info sits in.
const info = () => document.querySelector(".place-items-center.z-menu");
async function openSettings() {
fireEvent.click(screen.getByLabelText("plex.player.more"));
const row = await waitFor(() => {
const r = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.player.settings");
expect(r).toBeDefined();
return r!;
});
fireEvent.click(row);
await waitFor(() => expect(settings()).not.toBeNull());
}
describe("PlexPlayer · one panel at a time", () => {
it("the browser Back with the ⋯ menu open closes the menu, not the player", async () => {
const onClose = await mount();
fireEvent.click(screen.getByLabelText("plex.player.more"));
await waitFor(() => expect(moreMenu()).not.toBeNull());
// The menu claims a history entry, so the Back below is spent on it. First in the file: the
// history module's entry count is per module, and an earlier test's entries would be in it.
await waitFor(() => expect(window.history.state?._ov).toBeDefined());
await browserBack();
await waitFor(() => expect(moreMenu()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
it("the browser Back peels each layer that is up, one press at a time", async () => {
// One shared history entry for every layer: the first Back spent it on one layer, and the
// second left the player with the stats readout still up. Second in the file (see above): the
// first test leaves no entry behind.
window.history.pushState({ page: "player" }, ""); // the entry the player was opened on
const onClose = await mount();
press("d");
press("i");
await waitFor(() => expect(info()).not.toBeNull());
await browserBack();
await waitFor(() => expect(info()).toBeNull());
expect(stats()).not.toBeNull();
await browserBack();
await waitFor(() => expect(stats()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
it("the info overlay replaces the tuning panel rather than drawing over it", async () => {
await mount();
await openSettings();
press("i");
await waitFor(() => expect(info()).not.toBeNull());
expect(settings()).toBeNull();
});
it("the stats readout stays up beside a panel; Escape peels the panel, then the stats, then leaves", async () => {
const onClose = await mount();
press("d");
press("i");
await waitFor(() => expect(info()).not.toBeNull());
expect(stats()).not.toBeNull();
press("Escape");
expect(info()).toBeNull();
expect(stats()).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
press("Escape");
expect(stats()).toBeNull();
expect(onClose).not.toHaveBeenCalled();
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -308,6 +308,45 @@ describe("leaving an item that was played through", () => {
act(() => void el.dispatchEvent(new Event("ended")));
};
// A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history
// entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an
// open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog.
const overlayPushes = (push: { mock: { calls: unknown[][] } }) =>
push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null);
it("gives the auto-skip countdown no history entry", async () => {
prefs({ autoSkipIntro: true, autoSkipDelay: 5 });
const withIntro = {
...item("A"),
markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }],
} as unknown as PlexItemDetail;
mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } });
await playingAt("/media/A", 50);
await tick();
const push = vi.spyOn(window.history, "pushState");
await playingAt("/media/A", 110); // inside the intro: the countdown starts
await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy());
await tick();
expect(overlayPushes(push)).toEqual([]);
});
it("gives the up-next card no history entry", async () => {
prefs({ upNext: true });
mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
await tick();
const push = vi.spyOn(window.history, "pushState");
endMedia();
await screen.findByText("Title B"); // the card names the next item
await tick();
expect(overlayPushes(push)).toEqual([]);
});
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false });
const { qc, progress } = mount({
@@ -334,7 +373,7 @@ describe("leaving an item that was played through", () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
@@ -446,7 +485,7 @@ describe("leaving an item that was played through", () => {
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
markers: [{ type: "credits", start_s: 2000, end_s: 2990, final: true }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
@@ -463,6 +502,56 @@ describe("leaving an item that was played through", () => {
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
});
it("lets a credits block that does not end the item play on, its Skip only jumping past it", async () => {
// An item can carry several credits blocks with scenes between them: the first one moving on
// to the next item skipped a post-credit scene.
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const twoCredits = {
...item("A"),
markers: [
{ type: "credits", start_s: 2000, end_s: 2100, final: false },
{ type: "credits", start_s: 2900, end_s: 2995, final: true },
],
} as unknown as PlexItemDetail;
const { qc } = mount({
details: { A: twoCredits, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2010);
await tick();
expect(screen.queryByText("Title B")).toBeNull();
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("B", "en"))).toBeUndefined();
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
await waitFor(() => expect(startsAsked("A").slice(-1)).toEqual([2100])); // past the block, still A
expect(screen.queryByText("Title B")).toBeNull();
expect(setState).not.toHaveBeenCalled();
await playingAt("/media/A", 2910); // the final block: the auto-skip moves on
await screen.findByText("Title B");
expect(setState).toHaveBeenCalledWith("A", "watched");
});
it("does not leave on its own at credits Plex did not flag final, but its Skip does", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const unflagged = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: false }],
} as unknown as PlexItemDetail;
mount({
details: { A: unflagged, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2910);
await tick();
expect(screen.queryByText("Title B")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
await screen.findByText("Title B");
expect(setState).toHaveBeenCalledWith("A", "watched");
});
it("records a second viewing when the viewer plays on from the finished frame", async () => {
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
@@ -502,11 +591,18 @@ describe("restarting the session before playback has ticked", () => {
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
const answers = [deferred(), deferred(), deferred()];
let calls = 0;
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
// One request per item in flight: the switch waits for the start's answer…
await act(async () => answers[0]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
fireEvent.keyDown(window, { key: "ArrowRight" });
await act(async () => answers[1]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
@@ -529,12 +625,41 @@ it("a seek made while a restart is pending wins over it", async () => {
await playingAt("/media/A", 95);
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
await tick();
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
// on localdev, 2026-10-06).
let calls = 0;
const restart = deferred();
mount({
details: { A: item("A", 1200) },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? restart.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
await playingAt("/media/A", 1200);
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
await tick();
expect(startsAsked("A")).toEqual([1200, 1190]);
await act(async () => restart.resolve(direct("A")));
// The queued target is the LAST press's, asked once.
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },
@@ -645,3 +770,38 @@ it("a session answer that lands while the next item's detail is still loading is
await tick();
expect(video().getAttribute("src")).not.toBe("/media/B");
});
describe("the server session the player no longer wants", () => {
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
ReturnType<typeof api.plexSession>
>;
it("is stopped by its item and its own id when the page is unloaded", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
act(
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
);
expect(stop).toHaveBeenCalledWith("A", "sid-A");
});
it("is stopped the same way when the player closes", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
const { unmount } = mount({
details: { A: item("A") },
sessions: { A: () => Promise.resolve(hlsA) },
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
unmount();
expect(stop).toHaveBeenCalledWith("A", "sid-A");
});
it("is kept when the page only goes into the back/forward cache", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
expect(stop).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,12 @@ import { api, type PlexItemDetail } from "../lib/api";
import { pushLayer, removeLayer } from "../lib/layerStack";
beforeAll(() => {
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
class Stub {
observe() {}
unobserve() {}
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
thumb: "",
}) as unknown as PlexCard;
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void) {
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
const onPlay = vi.fn();
const onBack = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) client.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<QueryClientProvider client={client}>
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
</QueryClientProvider>,
);
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
});
describe("PlexPlaylistView · a row's menu", () => {
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
mount([movie("1")], undefined, { admin: true });
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect(menuRows()).toContain("plex.collectionAdd.row");
});
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
mount([movie("1"), ep("e1", 1, 1)]);
await screen.findByText("Movie 1");
+1 -1
View File
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
? []
: menuTarget.kind === "item"
? [
...titleActions.items(menuTarget.item),
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
]
+11 -44
View File
@@ -1,8 +1,8 @@
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
import type { HlsEngine } from "../lib/hlsEngine";
import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats";
import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
@@ -23,29 +23,24 @@ function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: numb
export function PlexStatsOverlay({
videoRef,
hlsRef,
engine,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
// long as this panel) — the source of the MEASURED bitrate row.
fragMeterRef: RefObject<FragMeter>;
audioLabel?: string; // selected audio track label, as the tracks menu names it
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const hls = hlsRef.current;
const { dropped, total } = frameCounts(v);
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
}
}
let level: HlsLevelInfo | null = null;
if (hls) {
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
level = {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
}
const level = engine.levelInfo();
setRows(
buildStatRows({
lang,
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
hwEncoder: engine.hw,
tonemap: engine.tonemap,
transport: level ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
measuredBps: fragMeterRef.current?.bitrate(),
audioLabel,
subLabel,
sessionStartS: sessionStartRef.current ?? 0,
sessionStartS: engine.sessionStart.current,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [
lang,
videoRef,
hlsRef,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
]);
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
return (
<div
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// The Plex player draws its subtitles itself. With the browser drawing them, the control bar covered
// the text on an iPad whenever it was up: a native cue is placed by `line`, which nothing animates.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { createRef } from "react";
import PlexSubtitles from "./PlexSubtitles";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
function fakeVideo() {
const video = document.createElement("video");
const list: FakeTrack[] = [];
const tracks = Object.assign(new EventTarget(), {
get length() {
return list.length;
},
[Symbol.iterator]: () => list[Symbol.iterator](),
});
Object.defineProperty(video, "textTracks", { value: tracks });
Object.defineProperty(video, "clientHeight", { value: 1000 });
const make = (own: boolean) => {
const track: FakeTrack = Object.assign(new EventTarget(), {
kind: "subtitles",
mode: "hidden" as TextTrackMode,
activeCues: [] as unknown[],
});
if (own) {
const el = document.createElement("track");
Object.defineProperty(el, "track", { value: track });
video.appendChild(el);
}
list.push(track);
return track;
};
const cuesOn = (track: FakeTrack, texts: string[]) =>
act(() => {
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
track.dispatchEvent(new Event("cuechange"));
});
const track = make(true);
const show = (...texts: string[]) => cuesOn(track, texts);
const addTrack = (own: boolean) => {
const added = make(own);
act(() => {
tracks.dispatchEvent(new Event("addtrack"));
});
return (...texts: string[]) => cuesOn(added, texts);
};
return { video, show, addTrack };
}
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
* the player is drawn that much larger than its own pixels, as the windowed player is. */
function layout(
video: HTMLVideoElement,
bar: HTMLElement,
textBottom: number,
barTop: number,
scale = 1,
) {
const rect = (top: number, bottom: number) =>
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
if (this === video) return rect(0, 1000);
if (this === bar) return rect(barTop, 1000);
// The outer box: the lift moves only the text inside it.
if (isBox(this)) return rect(textBottom - 80, textBottom);
return rect(0, 0);
});
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return isBox(this) ? 80 / scale : 0;
});
}
const lifted = () => screen.getByTestId("plex-subtitles-text");
function mount(video: HTMLVideoElement, avoid: boolean) {
const bar = document.createElement("div");
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
barRef.current = bar;
const props = {
video,
pos: 88,
size: 100,
color: "#ffffff",
background: "transparent",
shadowPx: 0,
shadowColor: "#000000",
avoidRef: barRef,
};
const view = render(<PlexSubtitles {...props} avoid={avoid} />);
return { bar, rerender: (a: boolean) => view.rerender(<PlexSubtitles {...props} avoid={a} />) };
}
describe("PlexSubtitles", () => {
it("shows the cues the browser reports active, at the cue type size of the video box", () => {
const { video, show } = fakeVideo();
mount(video, false);
show("Half full", "or half empty?");
const box = screen.getByTestId("plex-subtitles");
expect(box.textContent).toBe("Half fullor half empty?");
expect(box.style.fontSize).toBe("50px");
expect(box.style.top).toBe("88%");
});
it("rises above the control bar while it shows, and settles back when it hides", () => {
const { video, show } = fakeVideo();
const { bar, rerender } = mount(video, false);
layout(video, bar, 950, 900);
show("Half full");
expect(lifted().style.transform).toBe("translateY(0px)");
rerender(true);
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
expect(lifted().style.transform).toBe("translateY(-58px)");
rerender(false);
expect(lifted().style.transform).toBe("translateY(0px)");
});
it("lifts in the player's own pixels when the player is drawn scaled", () => {
const { video, show } = fakeVideo();
const { bar } = mount(video, true);
layout(video, bar, 950, 900, 1.25);
show("Half full");
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
expect(lifted().style.transform).toBe("translateY(-46px)");
});
it("places a cue that just appeared, and slides only one already on screen", () => {
const { video, show } = fakeVideo();
const { bar, rerender } = mount(video, true);
layout(video, bar, 950, 900);
show("Half full");
expect(lifted().style.transform).toBe("translateY(-58px)");
expect(lifted().className).not.toContain("transition-transform");
rerender(false);
expect(lifted().className).toContain("transition-transform");
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
show();
rerender(true);
show("or half empty?");
expect(lifted().style.transform).toBe("translateY(-58px)");
expect(lifted().className).not.toContain("transition-transform");
});
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
const { video, addTrack } = fakeVideo();
mount(video, false);
const showOnNew = addTrack(true);
showOnNew("After the seek");
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
});
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
const { video, addTrack } = fakeVideo();
mount(video, false);
const showInBand = addTrack(false);
showInBand("Embedded");
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
});
it("draws nothing while no cue is active", () => {
const { video } = fakeVideo();
mount(video, true);
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { subShadow } from "../lib/plexPlayerPrefs";
import { ownTextTracks } from "../lib/plexSubTracks";
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
// this layer shows them. The browser's own rendering could not move out of the way of the control
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
// settings style it directly.
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
const CUE_EM_OF_HEIGHT = 0.05;
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
const CLEARANCE_PX = 8;
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
const [cues, setCues] = useState<Cue[]>([]);
useEffect(() => {
if (!video) return;
const tracks = video.textTracks;
const read = () => {
const now: Cue[] = [];
const own = ownTextTracks(video);
for (const tr of Array.from(tracks)) {
if (!own.has(tr) || tr.mode === "disabled") continue;
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
}
setCues((prev) =>
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
);
};
const bound = new Set<TextTrack>();
const bind = (tr: TextTrack) => {
if (bound.has(tr)) return;
bound.add(tr);
tr.addEventListener("cuechange", read);
};
const onAdd = () => {
for (const tr of Array.from(tracks)) bind(tr);
read();
};
for (const tr of Array.from(tracks)) bind(tr);
tracks.addEventListener("addtrack", onAdd);
tracks.addEventListener("removetrack", read);
read();
return () => {
tracks.removeEventListener("addtrack", onAdd);
tracks.removeEventListener("removetrack", read);
for (const tr of bound) tr.removeEventListener("cuechange", read);
};
}, [video]);
return cues;
}
/** The video box's height, followed as the window or fullscreen changes it. */
function useHeight(el: HTMLElement | null): number {
const [height, setHeight] = useState(0);
useEffect(() => {
if (!el) return;
const measure = () => setHeight(el.clientHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [el]);
return height;
}
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
* markup from a string, so nothing in a subtitle file can run. */
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
const ref = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
else el.textContent = cue.text ?? "";
}, [cue]);
return (
<span
ref={ref}
style={style}
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
/>
);
}
export default function PlexSubtitles({
video,
pos,
size,
color,
background,
shadowPx,
shadowColor,
avoid,
avoidRef,
}: {
video: HTMLVideoElement | null;
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
pos: number;
size: number;
color: string;
background: string;
shadowPx: number;
shadowColor: string;
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
avoid: boolean;
avoidRef: RefObject<HTMLElement | null>;
}) {
const cues = useActiveCues(video);
const height = useHeight(video);
const boxRef = useRef<HTMLDivElement>(null);
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
// not slid there from where the previous cue was left.
const [lift, setLift] = useState({ px: 0, slide: false });
const shownRef = useRef(false);
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
// by that scale.
useLayoutEffect(() => {
const box = boxRef.current;
if (!box || !video || cues.length === 0) {
shownRef.current = false;
return;
}
const rect = box.getBoundingClientRect();
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
setLift({ px, slide: shownRef.current });
shownRef.current = true;
}, [avoid, avoidRef, cues, height, pos, size, video]);
if (cues.length === 0 || height === 0) return null;
const text: React.CSSProperties = {
color,
backgroundColor: background,
textShadow: subShadow(shadowPx, shadowColor),
};
return (
<div
ref={boxRef}
data-testid="plex-subtitles"
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
>
<div
data-testid="plex-subtitles-text"
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
style={{ transform: `translateY(${-lift.px}px)` }}
>
{cues.map((cue, i) => (
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
))}
</div>
</div>
);
}
+35 -3
View File
@@ -4,8 +4,8 @@
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import SearchBar from "./SearchBar";
vi.mock("react-i18next", async (importOriginal) => {
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
describe("SearchBar responsive wrapper", () => {
it("carries the container-query classes that make it wrap instead of collapsing", () => {
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const cls = (container.firstElementChild as HTMLElement).className;
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
expect(cls).toContain("@max-[640px]/appmain:basis-full");
expect(cls).toContain("@max-[640px]/appmain:order-last");
});
});
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
describe("SearchBar on a touch screen", () => {
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
const { container, getByRole } = render(
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
);
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
"pointer-coarse:hidden",
);
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
expect(pill.className).toContain("pointer-coarse:h-13");
});
it("shortens Go to its icon by the column's width, not the screen's", () => {
const { getByText } = render(
<SearchBar
value=""
onChange={() => {}}
placeholder="Search…"
onGo={() => {}}
goIcon={<svg />}
goLabel="Go far"
/>,
);
const label = getByText("Go far");
expect(label.className).toContain("@min-[900px]/appmain:inline");
expect(label.className).not.toMatch(/(^|\s)md:inline/);
});
});
+10 -5
View File
@@ -72,14 +72,18 @@ export default function SearchBar({
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
return (
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
44 pill sat on its edge. */}
<div
className={clsx(
fieldClass({ size: "free", pill: true, floating: true }),
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
)}
>
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
@@ -116,7 +120,8 @@ export default function SearchBar({
title={goTitle ?? goLabel ?? t("header.goTip")}
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</Button>
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
expect(onAdvance).not.toHaveBeenCalled();
});
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
// An episode Plex has no number for (a special) is named by its title alone everywhere — a "?"
// told the viewer nothing, and the player's own header already shows the title alone.
it("an episode heading drops the numbers when Plex has none for it", async () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "e7",
kind: "episode",
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
episode_number: null,
});
renderCard();
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
expect(await screen.findByText("Westworld")).toBeTruthy();
expect(screen.getByText("The Adversary")).toBeTruthy();
expect(screen.queryByText(/\?/)).toBeNull();
});
});
+7 -2
View File
@@ -6,6 +6,7 @@ import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { cardImage } from "../lib/plexImage";
import { episodeNumbers } from "../lib/plexDetail";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
@@ -55,10 +56,14 @@ export function UpNextCard({
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
// Title: "Show · S2·E5" over the episode title for episodes (the show alone when Plex has no
// number for the episode); the plain title for movies/queue items.
const nums = next ? episodeNumbers(next) : null;
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
? nums
? `${next.show_title} · S${nums.season}·E${nums.episode}`
: next.show_title
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
@@ -286,3 +286,41 @@ describe("WatchPage honest failures", () => {
);
});
});
// A shared link opens where THIS browser stopped in it (the viewer has no account), and keeps the
// point as it plays — per link, so another link starts at its own place.
describe("WatchPage resume", () => {
function loaded(video: HTMLVideoElement, duration = 600) {
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
it("keeps where it stopped, and opens there on the next visit", async () => {
window.history.replaceState({}, "", "/watch/tok-resume");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const first = render(<WatchPage />);
await waitFor(() => expect(first.container.querySelector("video")).not.toBeNull());
const video = loaded(first.container.querySelector("video")!);
expect(video.currentTime).toBe(0);
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
cleanup();
const second = render(<WatchPage />);
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
expect(loaded(second.container.querySelector("video")!).currentTime).toBe(250);
cleanup();
window.history.replaceState({}, "", "/watch/tok-other");
const other = render(<WatchPage />);
await waitFor(() => expect(other.container.querySelector("video")).not.toBeNull());
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});
+22 -42
View File
@@ -1,9 +1,12 @@
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
const init: MediaMetadataInit = {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
};
try {
navigator.mediaSession.metadata = new MediaMetadata(init);
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
}
type Meta = {
needs_password: boolean;
title?: string | null;
@@ -95,13 +64,24 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
+6 -1
View File
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
</div>
);
}
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
return (
<div className={THUMB_BOX}>
<div className={`${THUMB_BOX} relative`}>
<MediaProgress pct={pct} />
{src ? (
<img
src={src}
+35 -3
View File
@@ -11,7 +11,11 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
// The key, and the episode a "next" line names after a colon (`{ name }`).
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
}),
};
});
@@ -155,8 +159,8 @@ describe("PlexPosterCard with a next episode", () => {
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
@@ -370,6 +374,34 @@ describe("PlexPosterCard on a shelf", () => {
});
});
// An episode Plex has no number for (a special) is named by its title alone, as the player's own
// header names it — the cards printed a question mark where the number was missing.
describe("an episode card with no number for the episode", () => {
const special: PlexCard = {
...episode(),
title: "The Special",
show_id: "sh",
show_title: "Show",
season_number: 0,
episode_number: null,
};
it("names a show's episode by its title alone", () => {
render(<PlexPosterCard card={special} onOpen={vi.fn()} />);
expect(screen.queryByText("plex.episodeLine")).toBeNull();
expect(screen.getByText("The Special")).toBeTruthy();
});
it("names the next episode by its title alone", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
render(
<PlexPosterCard card={show} next={special} onOpen={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy();
});
});
// A card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
+16 -13
View File
@@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react";
import { formatDecimal, formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail";
import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -318,7 +318,8 @@ export function PlexPosterCard({
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
// The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep);
// 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 =
@@ -361,8 +362,10 @@ export function PlexPosterCard({
<HoverBar
watched={actedWatched}
watchedLabel={
epCode
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
nextName
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", {
name: nextName,
})
: actedWatched
? t("plex.markUnwatched")
: t("plex.markWatched")
@@ -389,14 +392,14 @@ export function PlexPosterCard({
meta={
asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.episodeLine", {
season: card.season_number ?? "?",
episode: card.episode_number ?? "?",
title: card.title,
})}
{episodeNumbers(card)
? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title })
: card.title}
</div>
) : nextName ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.nextUp", { name: nextName })}
</div>
) : epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
@@ -741,8 +744,8 @@ export function EpisodeCard({
ep.show_title && (
<div className="truncate text-xs text-muted">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
{episodeNumbers(ep)
? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}`
: ""}
</div>
)
@@ -15,6 +15,8 @@ import {
type KeyScope,
} from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { useMediaKeys } from "../../lib/mediaKeys";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
pressH();
expect(descriptions()).toContain("keys.episode");
});
// The library player's F fills the screen with a video; an audio file has no picture, so the
// sheet over an audio file does not offer it. Through the real hook, which publishes the answer.
it("offers Fullscreen over a video and not over an audio file", () => {
function Player({ tag }: { tag: "video" | "audio" }) {
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaKeys(media);
return tag === "video" ? <video ref={setMedia} /> : <audio ref={setMedia} />;
}
const { unmount } = render(
<Host>
<Player tag="audio" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.playPause");
expect(descriptions()).not.toContain("keys.fullscreen");
unmount();
closeKeyboardHelp();
render(
<Host>
<Player tag="video" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.fullscreen");
});
});
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { PopoverItem, PopoverList } from "./AnchoredPopover";
import { IconButton } from "./IconButton";
import { Input } from "./Input";
import { MenuCheck } from "./MenuCheck";
import { StateMessage } from "./QueryState";
// The body of a "which of these is it in" menu: a checkbox row per set (a playlist, a collection, a
// tag), and a field that names a new one. Rendered inside an anchored `menu` surface — the video
// card's "Add to playlist", a Plex title's "Add to collection", a channel's tags. One body, so a fix
// to its states or keys reaches all three: the copies had drifted (one said a failed load as "none").
//
// The surface around it is opened with `hostsInput: true` (no typeahead, no focus on hover), and the
// name field keeps its keys to itself — except Escape, which closes the menu, and the up/down arrows,
// which step from the field into the rows: the rows may arrive after the surface opened, when the
// popover's own first-row focus has already given up and left the focus in the field.
//
// The surface is opened with `listInside` too: the menu role sits on the rows' own box, and the lines
// about them (loading, a failed load with its Retry, none yet), the heading and the field sit beside
// it — a menu may own its items only. And when the state line swaps for the rows (or back), focus that
// was on its Retry would be left on <body> with the panel still open, the arrows dead: it is put back
// in the panel — on the first row, else the field, else the new Retry. A spot it was put on only
// because the rows were not there yet (the field, while a retry loads) hands on to the first row.
export interface MembershipRow {
id: string | number;
label: string;
checked: boolean;
/** A mark after the label (the YouTube glyph on a linked playlist). */
extra?: ReactNode;
}
export function MembershipMenu({
heading,
rows,
onToggle,
status = "ready",
onRetry,
emptyText,
errorText,
busy = false,
create,
dense = false,
}: {
heading?: string;
rows: MembershipRow[];
onToggle: (row: MembershipRow) => void;
/** The rows' load: `loading` and `error` say so instead of an empty list. */
status?: "loading" | "error" | "ready";
onRetry?: () => void;
emptyText: string;
errorText: string;
/** A write is in flight: said on the list (`aria-busy`). The rows stay enabled — a disabled button
* blurs the focused row to <body> mid-request and ends roving focus; the caller no-ops instead. */
busy?: boolean;
create?: {
placeholder: string;
ariaLabel?: string;
maxLength?: number;
/** With a label, a trailing button submits too; without, a leading + marks the field. */
submitLabel?: string;
/** Resolves true when the field should clear (the new set was made). */
onCreate: (name: string) => boolean | Promise<boolean>;
};
/** The smaller rows of a menu inside a table cell (a channel's tags). */
dense?: boolean;
}) {
const { t } = useTranslation();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const listRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const [draft, setDraft] = useState("");
const shownStatus = useRef(status);
const placed = useRef<HTMLElement | null>(null);
useEffect(() => {
if (shownStatus.current === status) return;
shownStatus.current = status;
// A frame later: rows that just mounted join the menu's list a render after this effect, and a
// row focused before that is not known to it — the arrows then did nothing from it.
const frame = requestAnimationFrame(() => {
const root = rootRef.current;
const at = document.activeElement;
// …unless a name is being typed into it meanwhile.
const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value);
const lost = at == null || at === document.body || parked;
if (!root || !lost) return;
const target =
root.querySelector<HTMLElement>("[role=menuitemcheckbox]") ??
root.querySelector<HTMLElement>("input") ??
root.querySelector<HTMLElement>("button");
target?.focus();
placed.current = target;
});
return () => cancelAnimationFrame(frame);
}, [status]);
async function submit() {
const name = draft.trim();
if (!name) return;
if (await create?.onCreate(name)) setDraft("");
}
function intoRows(key: string) {
const items = listRef.current?.querySelectorAll<HTMLElement>("[role=menuitemcheckbox]");
const row = key === "ArrowDown" ? items?.[0] : items?.[items.length - 1];
row?.focus();
}
const setList = (node: HTMLDivElement | null) => {
listRef.current = node;
fadeRef(node);
};
const rowText = dense ? "text-xs px-2" : "text-sm px-1.5";
return (
// A portal still bubbles through the React tree: keep a click here off the card that opened it.
<div ref={rootRef} className="flex flex-col" onClick={(e) => e.stopPropagation()}>
{heading && <div className="text-xs text-muted px-1.5 pb-1.5">{heading}</div>}
<div
ref={setList}
style={fadeStyle}
aria-busy={busy || status === "loading"}
className={`${dense ? "max-h-60" : "max-h-56"} overflow-y-auto no-scrollbar`}
>
{status === "loading" ? (
<StateMessage compact>{t("common.loading")}</StateMessage>
) : status === "error" ? (
<StateMessage compact tone="error" onRetry={onRetry}>
{errorText}
</StateMessage>
) : rows.length === 0 ? (
<p className={`text-xs text-muted py-2 ${dense ? "px-2" : "px-1.5"}`}>{emptyText}</p>
) : null}
<PopoverList>
{status === "ready" &&
rows.map((row) => (
<PopoverItem
key={row.id}
// A thing can sit in several sets at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={row.checked}
onSelect={() => onToggle(row)}
label={row.label}
className={`w-full flex items-center gap-2 ${rowText} py-1.5 rounded-lg outline-hidden transition ${
dense && !row.checked ? "text-muted" : "text-fg"
}`}
>
<MenuCheck kind="many" on={row.checked} />
<span className="truncate flex-1 text-left">{row.label}</span>
{row.extra}
</PopoverItem>
))}
</PopoverList>
</div>
{create && (
<div
className={`flex items-center border-t border-border ${dense ? "gap-1 mt-1" : "gap-1.5 mt-1.5"} pt-1.5`}
>
{!create.submitLabel && <Plus className="w-3.5 h-3.5 text-muted shrink-0" />}
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
intoRows(e.key);
} else if (e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
maxLength={create.maxLength}
placeholder={create.placeholder}
aria-label={create.ariaLabel}
/>
{create.submitLabel && (
<IconButton
variant="outline"
onClick={() => void submit()}
disabled={!draft.trim()}
label={create.submitLabel}
>
<Plus />
</IconButton>
)}
</div>
)}
</div>
);
}
@@ -85,3 +85,24 @@ describe("a dialog with an autoFocus child", () => {
stage.remove();
});
});
describe("a dialog whose own content goes fullscreen", () => {
it("stays where it is and keeps that content", () => {
// The library player is a <video> inside a dialog, and its fullscreen (the `f` key, or the
// video's own button) makes that video the fullscreen element. Moving the dialog's host INTO
// its own descendant is not a move the DOM allows: it threw, and the whole app went to the
// crash screen.
render(
<Modal initialFocus="close" title="Player" onClose={() => {}}>
<video aria-label="film" />
</Modal>,
);
const video = screen.getByLabelText("film");
const host = video.closest("body > div");
setFullscreen(video);
expect(video.isConnected).toBe(true);
expect(video.closest("body > div")).toBe(host);
});
});
+4 -2
View File
@@ -23,9 +23,10 @@ import { SURFACE } from "./surfaces";
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first
* the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no
* button). Required, so every dialog states its purpose instead of inheriting "the first
* focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action";
export type ModalFocus = "field" | "close" | "action" | "player";
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
@@ -101,6 +102,7 @@ export default function Modal({
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
initial: () =>
(initialFocus === "player" ? panelRef.current : null) ??
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
(closeRef.current?.offsetParent != null ? closeRef.current : null),
});
+1 -1
View File
@@ -37,7 +37,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
@@ -64,3 +64,41 @@ describe("an open tooltip", () => {
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
});
});
// On an iPad a tap's synthetic mouseenter showed the caption; WebKit then took the tap as a hover and
// swallowed its click (every rail button needed two taps), and with no mouseleave the caption stayed.
describe("a tooltip under a finger", () => {
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "";
}
}
const hover = (trigger: HTMLElement, pointerType: string) =>
act(() => {
trigger.dispatchEvent(new PointerEventStub("pointerdown", { bubbles: true, pointerType }));
trigger.dispatchEvent(new PointerEventStub("pointerenter", { pointerType }));
fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);
});
const mount = () => {
render(
<Tooltip hint="A hint" always>
<button>icon</button>
</Tooltip>,
);
return screen.getByText("icon").parentElement as HTMLElement;
};
it("does not open from a tap", async () => {
hover(mount(), "touch");
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("still opens under a mouse", async () => {
hover(mount(), "mouse");
expect(await screen.findByRole("tooltip")).toBeTruthy();
});
});
+2
View File
@@ -37,6 +37,8 @@ export default function Tooltip({
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
hoverDelay: { open: openDelay, close: 0 },
// A caption is for a mouse (and a key's focus). Opened by a tap it cost touch the tap itself.
mouseHoverOnly: true,
});
const { setReference } = pop.refs;
@@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions {
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
* and a tap is the card's play. */
pointerOnly?: boolean;
/** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a
* control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that
* first tap as a hover and swallowed its click (every rail button needed two taps), and with no
* mouseleave from a finger the caption stayed up. */
mouseHoverOnly?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
move: false,
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
mouseOnly: !isHover || !!opts.pointerOnly,
mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly,
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
// menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
+1 -1
View File
@@ -62,7 +62,7 @@ export function trapTab(
// second copy of the lock is gone.
let scrollLocks = 0;
let unlockedOverflow = "";
function lockBodyScroll(): () => void {
export function lockBodyScroll(): () => void {
if (scrollLocks++ === 0) {
unlockedOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
+26 -9
View File
@@ -13,6 +13,7 @@ import {
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -76,7 +77,7 @@ export function useDownloadActions({
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob) => void;
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
@@ -128,12 +129,28 @@ export function useDownloadActions({
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({
id: "play",
label: t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
});
// A started item continues, and can also start over — the card menus' words and glyph (C5).
const play = (job: DownloadJob): ActionMenuItem[] => {
const started = (job.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
@@ -200,7 +217,7 @@ export function useDownloadActions({
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? [play(job)] : []),
...(a.play ? play(job) : []),
{
id: "share",
label: t("downloads.actions.share"),
@@ -269,7 +286,7 @@ export function useDownloadActions({
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
+38 -4
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
import { pinPlex, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
@@ -46,15 +49,27 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
const copy = useCopyItems();
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
// own page).
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
// only a leaf is added from a card.
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
* without one the row is left out rather than opening the panel at a corner. Required, so every
* host menu decides: an optional one let a menu that has an anchor drop the row silently. */
const items = (
c: PlexCard,
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
{
play = true,
pin: withPin = true,
anchor,
}: { play?: boolean; pin?: boolean; anchor: Element | null },
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
@@ -104,6 +119,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
},
]
: []),
// A movie or a show can be a collection member; an episode cannot.
...(isAdmin && anchor && c.type !== "episode"
? [
{
id: "collection",
label: t("plex.collectionAdd.row"),
icon: <Layers className={icon} />,
separatorBefore: !playable,
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
},
]
: []),
...(playable && onInfo
? [
{
@@ -120,7 +147,14 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
];
};
const panel = adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
const panel = (
<>
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
{collecting && (
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
)}
</>
);
return { items, add, panel };
}
@@ -1,4 +1,26 @@
[
{
"version": "0.103.0",
"date": "2026-10-08",
"new": [
"Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page."
],
"fixed": [
"Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too.",
"iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen.",
"The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line."
]
},
{
"version": "0.102.0",
"date": "2026-10-08",
"new": [
"The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped."
],
"fixed": [
"Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream."
]
},
{
"version": "0.101.0",
"date": "2026-10-06",
@@ -1,4 +1,26 @@
[
{
"version": "0.103.0",
"date": "2026-10-08",
"new": [
"Plex: egy film vagy sorozat menüjében a „Hozzáadás gyűjteményhez…” bepipálja a címet a gyűjteményeidbe vagy kiveszi belőlük, illetve új gyűjteményt hoz létre vele — egy lépésben, a Gyűjtemények oldal megkerülése nélkül."
],
"fixed": [
"Plex-lejátszó: a felirat felemelkedik a vezérlősáv fölé, amíg az látszik, és visszacsúszik, amikor eltűnik, így a sáv sosem takarja el — iPaden sem.",
"iPad: a fejléc keresője illik az érintőképernyőhöz, a súgók nem ragadnak be, a Plex-lejátszó oldala nem görget és nincs kék sáv, a teljes képernyő gombja pedig rejtve marad, ahol a böngésző nem tud valódi teljes képernyőt.",
"A lejátszási lista, gyűjtemény és címke menük kiírják, ha a lista töltődik vagy nem töltődött be (újrapróbálással), nem mutatják üresnek, és billentyűzettel is kezelhetők. Továbbá kisebb javítások a YouTube-lejátszó címsorában."
]
},
{
"version": "0.102.0",
"date": "2026-10-08",
"new": [
"A könyvtár és a megosztott link lejátszója kezeli a billentyűzet és a rendszer médiagombjait, és onnan folytatja, ahol abbahagytad."
],
"fixed": [
"Plex-lejátszó: a böngésző Vissza gombja egyenként zárja be a videó fölötti paneleket, és csak a legvégén futó stáblista lép tovább a következő részre. Továbbá kisebb javítások a folytatási pontok és a stream leállítása körül."
]
},
{
"version": "0.101.0",
"date": "2026-10-06",
+9 -3
View File
@@ -121,9 +121,9 @@
"menu": "Media",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}",
"markNextWatched": "Mark {{name}} watched",
"markNextUnwatched": "Mark {{name}} unwatched",
"nextUp": "Next: {{name}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "Season {{n}}",
"specials": "Specials",
@@ -384,5 +384,11 @@
"byGenre": "Browse by genre",
"becauseWatched": "Because you watched {{title}}",
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
},
"collectionAdd": {
"row": "Add to collection…",
"title": "Add to collection",
"none": "No collection to add to yet — name one below.",
"loadFailed": "Couldn't load the collections"
}
}
+9 -3
View File
@@ -121,9 +121,9 @@
"menu": "Média",
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}",
"markNextWatched": "{{name}} megnézettnek jelöl",
"markNextUnwatched": "{{name}} nem-nézettnek jelöl",
"nextUp": "Következik: {{name}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "{{n}}. évad",
"specials": "Különkiadások",
@@ -384,5 +384,11 @@
"byGenre": "Böngészés műfaj szerint",
"becauseWatched": "Mert megnézted: {{title}}",
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
},
"collectionAdd": {
"row": "Kollekcióhoz adás…",
"title": "Kollekcióhoz adás",
"none": "Még nincs kollekció, amihez adhatnád – adj meg egy nevet lent.",
"loadFailed": "Nem sikerült betölteni a kollekciókat"
}
}
+28
View File
@@ -454,3 +454,31 @@ describe("uploadDownload's wire shape", () => {
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
});
});
// The library player re-reads its listings once its closing point is on the server: it cancelled
// their poll so an older answer could not put the point back, and only this callback brings them
// back. A beacon that never called it, or a slice that dropped it, left the lists stale with every
// test green.
describe("a beacon's settle callback", () => {
it("runs once the request is answered, and once it has failed", async () => {
const fetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockRejectedValueOnce(new TypeError("offline"));
const answered = vi.fn();
const failed = vi.fn();
beacon("/api/a", {}, answered);
beacon("/api/b", {}, failed);
await vi.waitFor(() =>
expect([answered.mock.calls.length, failed.mock.calls.length]).toEqual([1, 1]),
);
expect(fetch.mock.calls.map(([url]) => url).slice(-2)).toEqual(["/api/a", "/api/b"]);
});
it("is passed on by the library progress beacon", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 }));
const settled = vi.fn();
downloadsApi.saveDownloadProgressBeacon(7, 125.4, 600, settled);
await vi.waitFor(() => expect(settled).toHaveBeenCalledTimes(1));
});
});
+11 -6
View File
@@ -285,11 +285,13 @@ export function identityHeaders(): Record<string, string> {
};
}
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
export function beacon(url: string, body: Record<string, unknown>): void {
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
// still make: the resume checkpoints of the YouTube, Plex and library players, the Plex session stop,
// the diagnostics flush. A normal fetch is cancelled on unload and React effect cleanup doesn't run
// on a full reload, so without keepalive the last position (esp. right after a seek) would be lost.
// `onSettled`: the request has been answered (or failed) — for a caller whose cached view must be
// read again once the write is on the server.
export function beacon(url: string, body: Record<string, unknown>, onSettled?: () => void): void {
try {
void send(url, {
method: "POST",
@@ -297,7 +299,10 @@ export function beacon(url: string, body: Record<string, unknown>): void {
credentials: "include",
headers: { "Content-Type": "application/json", ...identityHeaders() },
body: JSON.stringify(body),
}).catch(() => {});
}).then(
() => onSettled?.(),
() => onSettled?.(),
);
} catch {
/* ignore */
}
+36 -1
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, uploadReq, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -97,6 +97,9 @@ export interface DownloadJob {
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
position_seconds?: number;
}
export interface ShareRecipient {
@@ -274,6 +277,38 @@ export const downloadsApi = {
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
saveDownloadProgress: (
id: number,
positionSeconds: number,
durationSeconds: number,
): Promise<{ ok: true; job_id: number; position_seconds: number }> =>
req(
`/api/downloads/${id}/progress`,
{
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true },
),
// The same, as a keepalive POST that survives the page going away (a reload, a closed tab).
saveDownloadProgressBeacon: (
id: number,
positionSeconds: number,
durationSeconds: number,
onSettled?: () => void,
): void =>
beacon(
`/api/downloads/${id}/progress`,
{
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
},
onSettled,
),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab
+17
View File
@@ -132,6 +132,8 @@ export interface PlexMarker {
type: "intro" | "credits";
start_s: number;
end_s: number;
/** Plex's own word that this credits block ends the item (see `lib/plexMarkers`). */
final: boolean;
}
// What the player names an audio/subtitle track from (`lib/vocab` trackLabel). Plex's own title
// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag.
@@ -412,6 +414,7 @@ export interface PlexFacets {
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession)
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
@@ -505,6 +508,12 @@ export const plexApi = {
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// A movie or show card's "Add to collection": its library's manual collections, each saying whether
// the title is in it already.
plexCollectionsFor: (
itemRk: string,
): Promise<{ collections: (PlexCollection & { contains: boolean })[] }> =>
req(`/api/plex/collections/for/${encodeURIComponent(itemRk)}`),
// One collection's hero + its members in the requested order (the Collection info page). The sort
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
@@ -717,6 +726,14 @@ export const plexApi = {
method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}),
// The player no longer wants this HLS session: its ffmpeg stops now instead of at the idle reaper.
// A keepalive POST, so it also goes out from a page being unloaded. The server only stops it while
// it is still the viewer's registered session for the item.
plexStopSession: (id: string, sessionId: string): void =>
beacon(
`/api/plex/stream/${encodeURIComponent(id)}/session/${encodeURIComponent(sessionId)}/stop`,
{},
),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { api } from "../api";
// The session stop is a keepalive beacon: nothing answers it on screen, so a URL that no longer
// matches the server's route fails silently — every transcode the player leaves runs on until the
// idle reaper. The route is read from the server's source, as the other contract tests do.
afterEach(() => vi.restoreAllMocks());
function serverRoute(): RegExp {
// Anchored to this file, not the working directory, so the test holds wherever vitest starts.
const source = readFileSync(
resolve(__dirname, "../../../../backend/app/routes/plex/playback.py"),
"utf8",
);
const prefix = /APIRouter\(prefix="([^"]+)"/.exec(source)?.[1];
const path = /@router\.post\("(\/stream\/[^"]*\/stop)"\)/.exec(source)?.[1];
// If either is missing the route was renamed or moved — re-point this test, the rule stands.
expect(prefix, "router prefix").toBeDefined();
expect(path, "the session stop route").toBeDefined();
// Each path parameter becomes a group of its own NAME: two ids that swapped places still match a
// pattern of anonymous wildcards, and the server then finds no session to stop.
const pattern = (prefix! + path!).replace(/\{(\w+)\}/g, "(?<$1>[^/]+)");
return new RegExp(`^${pattern}$`);
}
it("sends the session stop to the route the server serves it on", () => {
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null));
api.plexStopSession("12345", "a1b2c3");
const url = String(fetch.mock.calls[0]![0]);
expect(url.match(serverRoute())?.groups).toEqual({ rating_key: "12345", session_id: "a1b2c3" });
expect(fetch.mock.calls[0]![1]).toMatchObject({ method: "POST", keepalive: true });
});
+21 -3
View File
@@ -8,7 +8,7 @@ import {
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// Safari and iPadOS before 16.4 ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
@@ -29,11 +29,26 @@ export function fullscreenElement(): Element | null {
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Can this browser put a plain element in fullscreen at all? Read off `Element.prototype`, never
* `document.fullscreenEnabled`: Brave on iPadOS switches element fullscreen off in its web view, then
* patches `HTMLElement.prototype.requestFullscreen` with a stand-in that only fullscreens a <video>
* and forces `fullscreenEnabled` to true — both claim a support that is not there, and the players'
* fullscreen buttons did nothing. A button that cannot work is not shown (owner). */
export function canFullscreenElements(): boolean {
if (typeof Element === "undefined") return false;
const proto = Element.prototype as Partial<WebkitElement>;
return (
typeof proto.requestFullscreen === "function" ||
typeof proto.webkitRequestFullscreen === "function"
);
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
// Wrapped: a patched stand-in (Brave's, above) may return a boolean, not a promise.
if (e.requestFullscreen) void Promise.resolve(e.requestFullscreen()).catch(() => {});
else e.webkitRequestFullscreen?.();
}
@@ -95,7 +110,10 @@ export function useFullscreenPortalHost(): HTMLElement | null {
}, [host]);
useLayoutEffect(() => {
if (!host) return;
const parent = fsRoot ?? document.body;
// When the fullscreen element is the overlay's OWN content (a <video> inside a dialog, put in
// fullscreen by its key or its own button), it already shows; moving the host into its own
// descendant is a move the DOM refuses (HierarchyRequestError, the whole app crashed).
const parent = fsRoot && !host.contains(fsRoot) ? fsRoot : document.body;
if (host.parentNode === parent) return;
const focused = host.contains(document.activeElement)
? (document.activeElement as HTMLElement)
+62 -3
View File
@@ -55,6 +55,20 @@ afterEach(() => {
vi.clearAllMocks();
});
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
describe("useBackToExit back-ladder", () => {
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
const outer = vi.fn();
@@ -163,9 +177,9 @@ describe("closeOverlaysThenNavigate", () => {
}
});
// An overlay's Back callback is a Back STEP: the Plex player keeps its panels on one entry and peels
// one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`,
// Shift+W in the player closed the info panel and nothing else.
// An overlay's Back callback is a Back STEP, and the contract lets one entry hold several. When the
// Plex player kept its panels on one entry, a single call left the entry up and the navigation was
// dropped 1.5 s later — `i`, `d`, Shift+W in the player closed the info panel and nothing else.
it("asks again, a render apart, when one call peels only a step off the overlay", async () => {
const back = stubBack();
try {
@@ -329,3 +343,48 @@ describe("closeTopOverlayThenRun", () => {
}
});
});
// See useHistorySubview's back: a full tab can have dropped the entry under an overlay's own. The
// reconcile then has no entry of ours to step back to, and a back() would leave the document.
describe("an overlay whose entry has no entry of this document below it", () => {
it("drops its mark from the entry instead of stepping back out of the document", async () => {
const back = stubBack();
const layer = mountLayer(() => {});
await settle();
expect(window.history.state?._ov).toBeDefined();
stubNavigation([false]);
layer.unmount();
await settle();
expect(back).not.toHaveBeenCalled();
expect(window.history.state?._ov).toBeUndefined();
});
it("counts its own back()s still in flight: of two entries closing at once, only the first steps", async () => {
// Under the two overlay entries: one entry of this document, then another document's.
const landing: (() => void)[] = [];
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
landing.push(() =>
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })),
);
});
const lower = mountLayer(() => {});
const upper = mountLayer(() => {});
await settle();
stubNavigation([true, false]);
upper.unmount();
lower.unmount();
await settle();
expect(back).toHaveBeenCalledTimes(1);
act(() => landing.forEach((land) => land())); // the one back() lands, the books are square again
});
it("still steps back over its entry when the one below is this document's", async () => {
const back = stubBack();
const layer = mountLayer(() => {});
await settle();
stubNavigation([true]);
layer.unmount();
await settle();
expect(back).toHaveBeenCalledTimes(1);
});
});
+79 -3
View File
@@ -7,8 +7,8 @@
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview, type SubviewUrl } from "./history";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useBackToClose, useHistorySubview, type SubviewUrl } from "./history";
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors
// verbatim to ?v=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
@@ -27,7 +27,21 @@ const popTo = (search: string, state: unknown = null) =>
});
beforeEach(() => setUrl("/"));
afterEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
describe("useHistorySubview seed", () => {
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
@@ -170,3 +184,65 @@ describe("useHistorySubview entry", () => {
expect(window.history.state._sub).toEqual({ kind: "player" });
});
});
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
// left by a pushState with no user gesture — and that can be this page's own entry under the
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
// player started playing on its own).
describe("useHistorySubview back", () => {
it("goes back when the entry below is this document's", () => {
stubNavigation([true]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.back());
expect(back).toHaveBeenCalledTimes(1);
});
it("goes back as before where the browser has no Navigation API", () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.back());
expect(back).toHaveBeenCalledTimes(1);
});
// A sub-view left while an overlay of its own is still up — the Plex player's stats readout, a
// panel, the shortcuts sheet: the step back used to land as a USER Back, so the overlay listener
// spent it closing the top overlay and the sub-view stayed where it was.
it("closes the overlays standing over it first, then steps back out of the sub-view", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("player"));
const closed = vi.fn();
const holder: { unmount?: () => void } = {};
const layer = renderHook(() =>
useBackToClose(() => {
closed();
holder.unmount?.();
}),
);
holder.unmount = layer.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.state?._ov).toBeDefined();
act(() => result.current.back());
await waitFor(() => expect(result.current.view).toBe("root"));
expect(closed).toHaveBeenCalledTimes(1);
expect(window.history.state?._ov).toBeUndefined();
});
it("returns to the root in place when the entry below is another document's", () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const before = window.history.length;
act(() => result.current.back());
expect(back).not.toHaveBeenCalled();
expect(result.current.view).toBe("root");
expect(window.history.state?._sub).toBe("root");
expect(window.location.search).toBe("");
expect(window.history.length).toBe(before);
});
});
+56 -8
View File
@@ -12,6 +12,7 @@
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
import { useLatest } from "./useLatest";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
@@ -96,7 +97,29 @@ export function rememberNavPosition(): Record<string, unknown> {
return st;
}
// --- Is the entry a back() lands on still ours? --------------------------------------------
// Chrome keeps 50 entries a tab. When the list is full a new entry drops the OLDEST "skippable" one —
// an entry the page left by a pushState without a user gesture (an auto-skip countdown, a card that
// opened on its own) — not the oldest entry, so it can be this page's own entry under a sub-view.
// A programmatic back() then leaves the document for whatever older page sits below (measured
// 2026-10-07: an older entry whose restored Plex player started playing on its own). The Navigation
// API says which document the entry a back() lands on belongs to — counting the back()s of ours still
// in flight; a browser without it keeps the old assumption that our entries are all still there.
interface NavigationLike {
currentEntry: { index: number } | null;
entries: () => readonly { sameDocument: boolean }[];
}
/** Would one more history.back() still land in this document? */
export function backStaysInDocument(): boolean {
const nav = (window as { navigation?: NavigationLike }).navigation;
const at = nav?.currentEntry?.index;
if (nav == null || at == null) return true;
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true;
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
export interface SubviewUrl<T> {
@@ -151,7 +174,12 @@ export function useHistorySubview<T>(
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
// module functions and the setter, so there is nothing left to churn on.
const open = useCallback(
// Both move the module to another view, so both are navigations: they close the overlays standing
// over it first (`closeOverlaysThenNavigate`, synchronous when none is open). A step taken under an
// open overlay landed as a USER Back — the overlay listener spent it closing the top overlay, so
// leaving the Plex player with its stats readout up only closed the readout — and a push or a
// replace on top of an overlay's entry is undone when that entry is reclaimed.
const go = useCallback(
(next: T, opts?: { replace?: boolean }) => {
setView(next);
const kept = url?.entry ? url.entry(next) : next;
@@ -175,7 +203,21 @@ export function useHistorySubview<T>(
},
[url],
);
const back = useCallback(() => window.history.back(), []);
const open = useCallback(
(next: T, opts?: { replace?: boolean }) => closeOverlaysThenNavigate(() => go(next, opts)),
[go],
);
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
// the root view in place — a step up the module, not out of the app.
const rootRef = useLatest(root);
const back = useCallback(
() =>
closeOverlaysThenNavigate(() => {
if (backStaysInDocument()) window.history.back();
else go(rootRef.current, { replace: true });
}),
[go, rootRef],
);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, back }), [view, open, back]);
@@ -228,8 +270,14 @@ function reconcileHistory(): void {
}
while (historyDepth > want) {
historyDepth--;
pendingProgrammatic++;
window.history.back();
if (backStaysInDocument()) {
pendingProgrammatic++;
window.history.back();
} else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
}
}
syncWaiters.forEach((cb) => cb());
}
@@ -267,10 +315,10 @@ function historyInSync(): boolean {
// render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today,
// but a future "discard your changes?" guard must not be navigated out from under.
const CLOSE_TIMEOUT_MS = 1500;
// An overlay's Back callback is a Back STEP, and one entry can hold several steps: the Plex player's
// panels share a single entry and Back peels one per press. Navigating over it asks again, a render
// apart (the callback reads what the last one closed), until the entry is gone or the timeout drops
// the command.
// An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
// call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
// navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
// reads what the last one closed), until the entry is gone or the timeout drops the command.
const RECLOSE_MS = 50;
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
+570
View File
@@ -0,0 +1,570 @@
// The Plex player's session machine, without React, hls.js or a media pipeline: every race it exists
// to win, driven step by step. PlexPlayer's DOM tests hold the same behaviour through the component.
import { describe, it, expect, vi } from "vitest";
import type { PlexPlaySession } from "./api";
import {
HlsEngine,
MAX_SESSION_RECLAIMS,
RECLAIM_WINDOW_MS,
type EngineDeps,
type HlsHandlers,
type HlsPort,
type SessionParams,
} from "./hlsEngine";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
/** A <video> stand-in: the media calls the engine makes, and events the test fires. */
class FakeMedia extends EventTarget {
paused = true;
currentTime = 0;
src = "";
seekEnd = 0;
plays = 0;
get seekable() {
const end = this.seekEnd;
return { length: end > 0 ? 1 : 0, end: () => end };
}
play() {
this.plays++;
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {}
removeAttribute(name: string) {
if (name === "src") this.src = "";
}
fire(type: string) {
this.dispatchEvent(new Event(type));
}
}
class FakeHls implements HlsPort {
loaded: string | null = null;
destroyed = false;
audioTrack = 0;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
}
destroy() {
this.destroyed = true;
}
getAudioTrack() {
return this.audioTrack;
}
setAudioTrack(track: number) {
this.audioTrack = track;
}
levelInfo() {
return { index: 0, total: 1 };
}
lost404() {
this.handlers.onError({
fatal: true,
network: true,
status: 404,
type: "networkError",
details: "fragLoadError",
});
}
}
interface Ask {
id: string;
startAt: number;
params: SessionParams;
resolve(s: PlexPlaySession): Promise<void>;
reject(e: unknown): Promise<void>;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(over: Partial<EngineDeps> = {}) {
const media = new FakeMedia();
const asks: Ask[] = [];
const hlses: FakeHls[] = [];
const stops: [string, string][] = [];
let clock = 1_000_000;
const state = { position: 0, duration: 3000, params: { multi: false, audio: null, aoff: 0 } };
const engine = new HlsEngine({
media: () => media as unknown as HTMLMediaElement,
requestSession: (id, startAt, params) =>
new Promise<PlexPlaySession>((res, rej) => {
asks.push({
id,
startAt,
params,
resolve: async (s) => {
res(s);
await flush();
},
reject: async (e) => {
rej(e);
await flush();
},
});
}),
stopSession: (id, sid) => void stops.push([id, sid]),
createHls: (h) => {
const hls = new FakeHls(h);
hlses.push(hls);
return hls;
},
params: () => state.params,
position: () => state.position,
duration: () => state.duration,
now: () => clock,
statusOf: (e) => (e as { status?: number }).status,
...over,
});
return {
engine,
media,
asks,
hlses,
stops,
state,
advance: (ms: number) => void (clock += ms),
};
}
const hls = (id: string, start_s = 0, sid = `s-${id}-${start_s}`): PlexPlaySession => ({
mode: "hls",
url: `/hls/${id}/index.m3u8`,
start_s,
id: sid,
});
const direct = (id: string): PlexPlaySession => ({
mode: "direct",
url: `/media/${id}`,
start_s: 0,
});
describe("one request per item in flight", () => {
it("queues a restart asked for while the start is out, and sends only the LAST target", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
for (let t = 100; t <= 2000; t += 100) engine.seek("A", t);
expect(asks.map((a) => a.startAt)).toEqual([0]);
await asks[0]!.resolve(hls("A"));
expect(asks.map((a) => a.startAt)).toEqual([0, 2000]);
});
it("drops the stale answer it queued behind — it never attaches", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
engine.seek("A", 500);
await asks[0]!.resolve(hls("A", 0));
expect(hlses).toHaveLength(0);
await asks[1]!.resolve(hls("A", 500));
expect(hlses).toHaveLength(1);
expect(hlses[0]!.loaded).toBe("/hls/A/index.m3u8?t=500");
expect(media.src).toBe("");
});
it("still sends the queued target when the in-flight request fails, and shows no error for it", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 700);
await asks[0]!.reject({ status: 500 });
expect(asks.map((a) => a.startAt)).toEqual([0, 700]);
expect(engine.getSnapshot().error).toBeNull();
});
it("sends another item's request at once, and drops the first item's queue", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
engine.start("B", 0);
expect(asks.map((a) => [a.id, a.startAt])).toEqual([
["A", 0],
["B", 0],
]);
await asks[0]!.resolve(hls("A"));
await asks[1]!.resolve(direct("B"));
expect(asks).toHaveLength(2); // A's 900 is gone with A, whichever answer settles first
});
it("never sends a queued target of an item the viewer left before the next one started", async () => {
// The next item's detail may still be loading: nothing replaced the queue yet.
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
await asks[0]!.resolve(hls("A"));
expect(asks).toHaveLength(1);
});
it("reads the track parameters when the queued request is SENT", async () => {
const { engine, asks, state } = setup();
engine.start("A", 100);
engine.start("A", 100); // an audio switch while the start is out
state.params = { multi: false, audio: 1, aoff: 0 } as never;
await asks[0]!.resolve(hls("A", 100));
expect(asks[1]!.params.audio).toBe(1);
});
});
describe("answers that land late", () => {
it("are dropped after the viewer moved to another item — and the server session is stopped", async () => {
const { engine, asks, hlses, stops } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(hlses).toHaveLength(0);
expect(stops).toEqual([["A", "sA"]]);
});
it("are dropped after the player closed", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
engine.destroy(media as unknown as HTMLMediaElement);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("");
});
it("land again once a StrictMode remount revived the engine", async () => {
const { engine, asks, media } = setup();
engine.destroy(media as unknown as HTMLMediaElement);
engine.revive();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("/media/A");
});
it("a superseded direct item's loadedmetadata does not seek the next one", async () => {
const { engine, asks, media } = setup();
engine.start("A", 1200);
await asks[0]!.resolve(direct("A"));
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
media.fire("loadedmetadata");
expect(media.currentTime).toBe(0);
});
});
describe("the server session", () => {
it("is stopped when the viewer leaves the item, and when the player closes", async () => {
const { engine, asks, stops, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
engine.start("B", 0);
await asks[1]!.resolve(hls("B", 0, "sB"));
engine.destroy(media as unknown as HTMLMediaElement);
expect(stops).toEqual([
["A", "sA"],
["B", "sB"],
]);
});
it("is not stopped by a restart of the same item — the server replaces it itself", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000, "s2"));
expect(stops).toEqual([]);
});
it("is stopped when a direct stream of the same item replaces it", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.start("A", 0); // an A/V offset back at 0 plays the file directly
await asks[1]!.resolve(direct("A"));
expect(stops).toEqual([["A", "s1"]]);
});
it("is stopped when Safari plays it natively too (no hls.js)", async () => {
const { engine, asks, stops, media } = setup({ createHls: () => null });
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(media.src).toBe("/hls/A/index.m3u8");
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
});
it("is stopped on an unload, but not when the page goes into the back/forward cache", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.pageHidden(true);
expect(stops).toEqual([]);
engine.pageHidden(false);
expect(stops).toEqual([["A", "sA"]]);
});
});
describe("a failed session request", () => {
it.each([
[404, "notFound"],
[501, "transcode"],
[500, "generic"],
] as const)("%s says %s", async (status, error) => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status });
expect(engine.getSnapshot().error).toBe(error);
});
it("keeps the old stream's clock after a failed seek-restart, not after a failed move", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
engine.seek("A", 2000);
expect(engine.clockLive).toBe(false);
await asks[1]!.reject({ status: 500 });
expect(engine.clockLive).toBe(true);
engine.leaveItem();
engine.start("B", 0);
await asks[2]!.reject({ status: 404 });
expect(engine.clockLive).toBe(false);
});
});
describe("a session the server no longer has (404)", () => {
it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
state.position = 1299;
hlses[0]!.lost404();
expect(asks[1]!.startAt).toBe(1299);
});
it(`gives up after ${MAX_SESSION_RECLAIMS} reclaims inside the window, and says so`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("rolls the window: losses closer than it keep counting, however slow", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS * 0.6);
}
expect(engine.getSnapshot().error).toBe("generic");
});
it("starts over for a loss long after the last one, and for a new item", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i < 4; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS + 1);
}
expect(engine.getSnapshot().error).toBeNull();
const fresh = setup();
fresh.engine.start("A", 0);
for (let i = 0; i < MAX_SESSION_RECLAIMS; i++) {
await fresh.asks[i]!.resolve(hls("A"));
fresh.hlses[i]!.lost404();
}
fresh.engine.newItem();
await fresh.asks[MAX_SESSION_RECLAIMS]!.resolve(hls("A"));
fresh.hlses[MAX_SESSION_RECLAIMS]!.lost404();
expect(fresh.engine.getSnapshot().error).toBeNull();
});
it("keeps the start's own intent to play when it was lost before it ever played", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.lost404(); // media is still paused: not the viewer's pause
await asks[1]!.resolve(hls("A", 600));
hlses[1]!.handlers.onManifestParsed();
expect(media.plays).toBe(1);
});
});
describe("play intent while a session is asked for", () => {
it("goes into the pending session, and never plays the outgoing media", async () => {
const { engine, asks, media, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.fire("loadedmetadata");
expect(media.paused).toBe(false);
engine.seek("A", 2000, false); // a restart while paused-by-request
expect(engine.requestPlay(false)).toBe(true);
expect(media.paused).toBe(true);
const plays = media.plays;
expect(engine.requestPlay(true)).toBe(true);
expect(media.plays).toBe(plays); // the outgoing stream stays silent
expect(engine.shownPlaying()).toBe(true);
await asks[1]!.resolve(hls("A", 2000));
hlses[0]!.handlers.onManifestParsed();
expect(media.plays).toBe(plays + 1);
});
});
describe("seeking", () => {
it("a restart asked for before the new session has played keeps that session's intent to play", async () => {
// Attaching a new session empties the media: it reads paused until hls.js parses and plays. A
// seek in that window read the paused element as "the viewer paused", and the next session
// attached paused at 0:00 (measured: a burst of ← ending in a frozen frame).
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0));
expect(media.paused).toBe(true); // attached, not played yet
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000));
hlses[1]!.handlers.onManifestParsed();
expect(media.paused).toBe(false);
});
it("is native inside the attached session's loaded region", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 650);
expect(asks).toHaveLength(1);
expect(media.currentTime).toBeCloseTo(650 - (600 - HLS_SUB_LAG));
});
it("restarts past it, and while the clock is not the current session's", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 900);
expect(asks.map((a) => a.startAt)).toEqual([0, 900]);
engine.seek("A", 650); // inside the OLD buffer, but a restart is pending
await asks[1]!.resolve(hls("A", 900));
expect(asks.map((a) => a.startAt)).toEqual([0, 900, 650]);
});
it("clamps the target to the item", () => {
const { engine } = setup();
expect(engine.clamp(-5)).toBe(0);
expect(engine.clamp(99_999)).toBe(2999);
});
});
describe("the session's clock", () => {
it("folds hls.js's lag into a resumed HLS session's start, not a direct one's", async () => {
const { engine, asks } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
expect(engine.getSnapshot().sessionK).toBeCloseTo(600 - HLS_SUB_LAG);
engine.start("A", 0);
await asks[1]!.resolve(direct("A"));
expect(engine.getSnapshot().sessionK).toBe(0);
});
it("is not trusted between asking for a session and its answer", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.currentTime = 50;
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBe(50);
engine.releaseClock();
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBeNull();
});
it("answers 'where the viewer is' with the asked start before playback has ticked", () => {
const { engine, state } = setup();
engine.start("A", 1200);
expect(engine.playhead()).toBe(1200);
state.position = 1250;
expect(engine.playhead()).toBe(1250);
});
});
describe("audio on a multi-rendition session", () => {
it("restores the chosen track once hls.js lists the renditions, and switches client-side", async () => {
const { engine, asks, hlses, state } = setup();
state.params = { multi: true, audio: 1, aoff: 0 } as never;
expect(engine.setAudioTrack(1)).toBe(false); // nothing attached yet: the caller restarts
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onAudioTracksUpdated();
expect(hlses[0]!.audioTrack).toBe(1);
expect(engine.setAudioTrack(0)).toBe(true);
expect(hlses[0]!.audioTrack).toBe(0);
expect(asks).toHaveLength(1);
});
});
it("tells React only when the snapshot really changed", async () => {
const { engine, asks } = setup();
const seen = vi.fn();
engine.subscribe(seen);
engine.start("A", 0);
const after = seen.mock.calls.length;
engine.start("A", 0); // same ready/error/transcode — nothing new to render
expect(seen.mock.calls.length).toBe(after);
await asks[0]!.resolve(direct("A"));
});
describe("an item the viewer comes back to while its request is still out", () => {
it("waits for that request instead of sending a second one beside it (A → B → A)", async () => {
// The in-flight request was tracked in one slot: B's request took it, and the way back to A
// sent A2 next to the A1 still out — two spawns for one item, and the loser's answer could
// name the winner's session for the stop.
const { engine, asks } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
engine.leaveItem();
engine.start("A", 0);
expect(asks.map((a) => a.id)).toEqual(["A", "B"]);
await asks[0]!.resolve(hls("A", 0, "s1"));
expect(asks.map((a) => a.id)).toEqual(["A", "B", "A"]);
});
});
describe("a session that was replaced before its manifest parsed", () => {
it("neither marks the player ready nor plays the outgoing media", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.seek("A", 900); // nothing seekable yet: a restart
const plays = media.plays;
hlses[0]!.handlers.onManifestParsed(); // the replaced instance parses late
expect(engine.getSnapshot().ready).toBe(false);
expect(media.plays).toBe(plays);
});
});
describe("a pause given after the answer, before the media can play", () => {
it("stands: neither the manifest nor the first canplay plays it again", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.requestPlay(false);
hlses[0]!.handlers.onManifestParsed();
expect(media.paused).toBe(true);
media.fire("canplay");
expect(media.paused).toBe(true);
});
it("stands after the manifest played it too", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
engine.requestPlay(false);
media.fire("canplay");
expect(media.paused).toBe(true);
});
});
+556
View File
@@ -0,0 +1,556 @@
import type { PlexPlaySession } from "./api";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
import type { HlsLevelInfo } from "./plexStats";
// The Plex player's session machine: which server session the <video> is playing, what a seek or a
// track change asks the server for, and which answers still belong to anything. Plain TypeScript with
// its collaborators injected (the session API, hls.js behind `HlsPort`, the media element), so every
// race it exists to win is a unit test without a DOM (hlsEngine.test.ts); PlexPlayer only renders it.
//
// The server keeps ONE HLS session per viewer per item and kills the previous ffmpeg on every new
// request for it. So the engine keeps at most one request per item in flight: a seek, track change or
// reclaim asked for while one is out replaces the QUEUED target, and the queue is sent once when the
// in-flight answer settles — that answer is then stale and dropped. Each request used to go out at
// once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg
// spawns, and five seconds before the picture moved again.
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2;
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
// the first fragment of any session, and a reclaimed session essentially always delivers one —
// `start_session` does not return until its first segment exists. So both cases `onHlsError` names
// restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
export const RECLAIM_WINDOW_MS = 120_000;
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
/** What the player renders from the engine. A new object whenever any of it changes. */
export interface EngineSnapshot {
/** The current session's media can play (its manifest parsed / its metadata loaded). */
ready: boolean;
error: EngineError | null;
/** The current session re-encodes the video: its cold start is honestly slower ("Preparing…"). */
transcode: boolean;
/** The current session's real media start, lag-compensated — the subtitle shift keys off it. */
sessionK: number;
}
/** A fatal-or-not hls.js error, reduced to what the engine decides on. */
export interface HlsFailure {
fatal: boolean;
network: boolean;
status: number | null;
type: string;
details: string;
}
/** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */
export interface HlsPort {
load(url: string, media: HTMLMediaElement): void;
destroy(): void;
getAudioTrack(): number;
setAudioTrack(track: number): void;
levelInfo(): HlsLevelInfo;
}
export interface HlsHandlers {
onManifestParsed(): void;
onAudioTracksUpdated(): void;
onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void;
onError(e: HlsFailure): void;
}
/** The track parameters a session request carries, read when it is SENT (a queued request goes out
* with whatever the viewer has picked by then). */
export interface SessionParams {
/** ≥2 audio tracks: one multi-rendition session, audio switched client-side. */
multi: boolean;
/** The single-rendition session's audio ordinal (ignored when `multi`). */
audio: number | null;
/** The A/V offset, seconds. */
aoff: number;
}
export interface EngineDeps {
media(): HTMLMediaElement | null;
requestSession(id: string, startAt: number, p: SessionParams): Promise<PlexPlaySession>;
/** Tell the server a session is no longer wanted (its ffmpeg stops at once). Fire-and-forget. */
stopSession(id: string, sessionId: string): void;
/** A new hls.js instance, or null where hls.js cannot run (the media plays the URL natively). */
createHls(handlers: HlsHandlers): HlsPort | null;
params(): SessionParams;
/** Where the viewer is, absolute seconds (0 = not ticked yet). */
position(): number;
/** The item's duration, seconds (0 = unknown). */
duration(): number;
now(): number;
/** An HTTP status from a failed session request (undefined = not an HTTP error). */
statusOf(e: unknown): number | undefined;
/** A request for a session went out: the player clears its stall spinner. */
onLoadStart?(): void;
onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void;
onHlsError?(e: HlsFailure): void;
}
/** Play intent while a session request is in flight. `said`: the viewer changed it since. */
interface Intent {
play: boolean;
said?: boolean;
}
// What an engine does before `connect`: nothing. It is created during render, where its owner may not
// hand it anything that reads a ref, and connected from a layout effect before any effect can use it.
const UNCONNECTED: EngineDeps = {
media: () => null,
requestSession: () => new Promise(() => {}),
stopSession: () => {},
createHls: () => null,
params: () => ({ multi: false, audio: null, aoff: 0 }),
position: () => 0,
duration: () => 0,
now: () => 0,
statusOf: () => undefined,
};
interface Request {
id: string;
startAt: number;
intent: Intent;
gen: number;
}
export class HlsEngine {
/** The absolute offset the attached session begins at (a ref-shaped box: hooks read `.current`). */
readonly sessionStart = { current: 0 };
/** Which encoder the current transcode ran: true = VAAPI, false = libx264, null = not a transcode. */
hw: boolean | null = null;
/** The current HW transcode tone-mapped HDR→SDR on the GPU. */
tonemap: boolean | null = null;
private deps: EngineDeps;
private snapshot: EngineSnapshot = { ready: false, error: null, transcode: false, sessionK: 0 };
private readonly listeners = new Set<() => void>();
private alive = true;
// Which request is the current one. Everything after a request's `await` checks it: an answer
// that lands after a newer request, an item change or the player's close is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to
// the detached <video> after Back, never destroyed, keeping the server session warm.
private gen = 0;
private hls: HlsPort | null = null;
// The server session the media is playing (HLS, by hls.js or natively): what `stopSession` names on
// the way out.
private attachedSession: { id: string; sessionId: string } | null = null;
// Removes the media listeners the current session put on the <video>. A newer session, an item
// change and the close all call it: the generation guards the `await`, and these fire LATER — a
// superseded direct-play item's loadedmetadata used to run on the next item's media and seek it to
// the old item's resume point, or start the old media playing under the new title.
private detachSession: () => void = () => {};
// Whether the media's clock belongs to the session `sessionStart` describes. False from the moment
// a session is asked for until it has answered: the media still attached meanwhile is the OLD one,
// so `sessionStart + currentTime` was the previous item's position — sent as the new item's subtitle
// position and saved as its progress, or, during a seek-restart, the pre-seek spot over the target.
private live = false;
// The play intent of the session being asked for, null while none is out. Until the answer
// attaches, the media is the OUTGOING one, and a play() there sounded the old stream while the new
// one still attached paused.
private pending: Intent | null = null;
// The item whose session is attached to the media right now (see the failure path).
private attachedId: string | null = null;
// The attached session's play intent, which stands until it is ready (see `wantsPlay`).
private attachedIntent: Intent | null = null;
// The request out for each item. Per item, not one slot: another item's request took the slot, and
// the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
// Where the current — or still in-flight — session was ASKED to start. Written before the request
// goes out, so "where the viewer is" has an answer before the first timeupdate.
private loadStart = 0;
constructor(deps: EngineDeps = UNCONNECTED) {
this.deps = deps;
}
/** Hand the engine its collaborators (see UNCONNECTED). */
connect(deps: EngineDeps): void {
this.deps = deps;
}
// --- the React side ------------------------------------------------------------------------
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getSnapshot = (): EngineSnapshot => this.snapshot;
private set(patch: Partial<EngineSnapshot>): void {
const next = { ...this.snapshot, ...patch };
const same = (Object.keys(next) as (keyof EngineSnapshot)[]).every(
(k) => next[k] === this.snapshot[k],
);
if (same) return;
this.snapshot = next;
this.listeners.forEach((fn) => fn());
}
/** The media's clock is the current session's (see `live`). */
get clockLive(): boolean {
return this.live;
}
/** The video level hls.js plays, or null for a direct stream. */
levelInfo(): HlsLevelInfo | null {
return this.hls ? this.hls.levelInfo() : null;
}
/** The player mounted (again — StrictMode replays a mount): answers may land on it once more. */
revive(): void {
this.alive = true;
}
/** The player closed: nothing in flight lands any more, the stream and its server session go,
* and the <video> stops — a late play() must not leave audio playing on a detached element. The
* element is the caller's: React has detached its ref by the time an unmount cleanup runs. */
destroy(media: HTMLMediaElement | null): void {
this.alive = false;
this.leaveItem();
if (!media) return;
try {
media.pause();
media.removeAttribute("src");
media.load();
} catch {
/* ignore */
}
}
/** The page is being unloaded for good (not kept in the back/forward cache): the server session
* stops now, since no effect cleanup runs on an unload. */
pageHidden(persisted: boolean): void {
const s = this.attachedSession;
if (persisted || !s) return;
this.attachedSession = null;
this.deps.stopSession(s.id, s.sessionId);
}
/** A fresh item is a fresh incident: the reclaim budget must not carry over from the previous one
* (the player stays mounted across a queue advance). */
newItem(): void {
this.reclaims = { n: 0, at: 0 };
}
/** Leaving the item: a session still in flight for it must not land after it, its stream goes,
* and the server stops producing it. */
leaveItem(): void {
this.gen++; // a queued request of this item is now stale, and is never sent
this.detachSession();
this.detachSession = () => {};
this.dropHls();
}
/** The viewer is moving to another item: the media's clock stops being trusted at once (the
* timeupdate the outgoing pause() queues lands after the reset and would hand the old position on). */
releaseClock(): void {
this.live = false;
}
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). */
start(id: string, startAt: number, play = true): void {
if (!this.deps.media()) return;
const gen = ++this.gen;
this.detachSession();
this.detachSession = () => {};
this.live = false;
const intent: Intent = { play };
this.pending = intent;
this.loadStart = startAt;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
const req: Request = { id, startAt, intent, gen };
if (this.inFlight.has(id)) this.queued = req;
else this.send(req);
}
private send(req: Request): void {
this.queued = null;
this.inFlight.set(req.id, req);
void this.deps.requestSession(req.id, req.startAt, this.deps.params()).then(
(sess) => this.answered(req, sess),
(e: unknown) => this.failed(req, e),
);
}
private current(req: Request): boolean {
return req.gen === this.gen && this.alive;
}
/** The in-flight request settled: the queued one goes now, unless it is stale too (the viewer
* left the item, or the player closed). */
private settledInFlight(req: Request): void {
if (this.inFlight.get(req.id) === req) this.inFlight.delete(req.id);
const next = this.queued;
if (!next || this.inFlight.has(next.id)) return;
this.queued = null;
if (this.alive && next.gen === this.gen) this.send(next);
}
private failed(req: Request, e: unknown): void {
this.settledInFlight(req);
if (!this.current(req)) return; // superseded or left: not this request's error to show
if (this.pending === req.intent) this.pending = null;
// A failed seek-restart leaves the old session attached and its clock still true; a failed move
// to another item leaves the previous item's media, which is not this item's clock.
this.live = this.attachedId === req.id;
// Say WHY playback can't start instead of an eternal spinner. 404 = the physical file isn't
// reachable (a missing media mount or a wrong path map); 501 = the codec needs a full transcode.
const status = this.deps.statusOf(e);
this.set({ error: status === 501 ? "transcode" : status === 404 ? "notFound" : "generic" });
}
private answered(req: Request, sess: PlexPlaySession): void {
this.settledInFlight(req);
if (!this.current(req)) {
// Nobody will play it: say so, or its ffmpeg runs on until the reaper. The server matches the
// id, so a newer session for the same item that has replaced it meanwhile is left alone.
if (sess.mode === "hls" && sess.id) this.deps.stopSession(req.id, sess.id);
return;
}
const media = this.deps.media();
if (!media) return;
const { intent, startAt, id } = req;
const isTranscode = sess.mode === "hls" && !!sess.transcode;
this.hw = isTranscode ? (sess.hw ?? false) : null;
this.tonemap = isTranscode ? (sess.tonemap ?? false) : null;
// hls.js plays a non-zero-start copyts stream ~1 s BEHIND the media clock (see HLS_SUB_LAG); fold
// that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct play (start_s 0) uses the file's real clock.
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
this.sessionStart.current = trueStart;
// From here the media's clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
this.live = true;
this.attachedId = id;
this.attachedIntent = intent;
if (this.pending === intent) this.pending = null;
this.set({ transcode: isTranscode, sessionK: trueStart });
// Belt and braces: the play() on MANIFEST_PARSED / loadedmetadata can be rejected when the media
// is not ready yet (slow first segments over a proxy), leaving the video paused after a seek the
// viewer was watching through. Retried once the element can play; the intent is read when it
// fires, since the viewer may have asked for play or pause meanwhile (`requestPlay` keeps it).
this.listen(media, req, "canplay", () => {
if (intent.play && media.paused) media.play().catch(() => {});
});
// The server replaced the previous HLS session for this item already — unless this answer is a
// direct stream (an A/V offset back at 0), which replaces nothing there.
this.dropHls(sess.mode === "hls" ? id : undefined);
// Named for the stop on the way out whoever plays it: hls.js, or Safari's native HLS below.
if (sess.mode === "hls" && sess.id) this.attachedSession = { id, sessionId: sess.id };
const hls = sess.mode === "hls" ? this.deps.createHls(this.hlsHandlers(req, media)) : null;
if (hls) {
this.hls = hls;
hls.load(`${sess.url}?t=${Math.floor(startAt)}`, media);
return;
}
// A direct file (or Safari's native HLS).
media.src = sess.url;
media.load();
this.listen(media, req, "loadedmetadata", () => {
if (sess.mode === "direct" && startAt > 0) media.currentTime = startAt;
this.set({ ready: true });
if (intent.play) media.play().catch(() => {});
});
}
/** A once-listener on the media that runs only while `req` is the current session. */
private listen(media: HTMLMediaElement, req: Request, type: string, fn: () => void): void {
const guarded = () => {
if (this.current(req)) fn();
};
media.addEventListener(type, guarded, { once: true });
const prev = this.detachSession;
this.detachSession = () => {
prev();
media.removeEventListener(type, guarded);
};
}
/** Destroy the hls.js instance, and stop the attached server session unless it belongs to
* `keepFor` (the server replaces a viewer's session for one item by itself). */
private dropHls(keepFor?: string): void {
const s = this.attachedSession;
this.attachedSession = null;
if (s && s.id !== keepFor) this.deps.stopSession(s.id, s.sessionId);
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
}
private hlsHandlers(req: Request, media: HTMLMediaElement): HlsHandlers {
return {
// Only the current session's: an instance replaced before its manifest parsed (a restart asked
// for right after an attach) marked the player ready while the next session was still being
// asked for — so that one then attached past `wantsPlay`'s not-ready guard — and played the
// outgoing media.
onManifestParsed: () => {
if (!this.current(req)) return;
this.set({ ready: true });
if (req.intent.play) media.play().catch(() => {});
},
// The selected audio is restored once hls.js has the renditions: on MANIFEST_PARSED the list
// was not populated yet, the choice was dropped, and the default track played under the
// restored selection.
onAudioTracksUpdated: () => {
const p = this.deps.params();
if (!p.multi || !this.hls) return;
const want = p.audio ?? 0;
if (this.hls.getAudioTrack() === want) return;
try {
this.hls.setAudioTrack(want);
} catch {
/* out of range — ignore */
}
},
onFragLoaded: (frag) => this.deps.onFragLoaded?.(frag),
onError: (e) => this.onHlsError(req, media, e),
};
}
// A fatal HLS error (the session died, the file became unreadable) would otherwise leave the
// spinner up forever — it is said instead.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two
// requests for one item: the server's LAST spawn wins). The server answers a superseded session
// exactly so the client asks again; the reclaim is that asking.
//
// Bounded by the budget, not by whether the session got as far as playing: that belongs to ONE
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers
// 404 for "that segment is not there YET" — indistinguishable on the wire — so a transcode falling
// behind realtime restarted ffmpeg forever behind a spinner that never became an error.
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return;
if (e.network && e.status === 404) {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n < MAX_SESSION_RECLAIMS) {
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media));
return;
}
}
this.set({ error: "generic" });
}
/** Where the viewer is: the ticked position, or — before playback has ticked (a session still
* loading, or a resumed one that has stayed paused) — where the session was ASKED to start.
* Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00
* from 0:00, and the next checkpoint saved that. */
playhead(): number {
const p = this.deps.position();
return p > 0 ? p : this.loadStart;
}
/** The absolute time of the media's clock, or null while it is not the current session's. */
absTime(media: HTMLMediaElement): number | null {
return this.live ? this.sessionStart.current + media.currentTime : null;
}
/** A seek target inside the item: not before 0, not past its last second. */
clamp(target: number): number {
const dur = this.deps.duration() || target;
return Math.max(0, Math.min(target, dur - 1));
}
/** Seek to absolute `target` (clamped): natively inside the attached session's loaded region,
* else a session restart there. `play`: the caller's intent across the seek; else what the media
* was doing. */
seek(id: string, target: number, play?: boolean): void {
const media = this.deps.media();
const T = this.clamp(target);
if (!media) return;
const wasPlaying = play ?? this.wantsPlay(media);
const rel = T - this.sessionStart.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending
// restart current, and its answer then jumped to the earlier target.
const end = media.seekable.length ? media.seekable.end(0) : 0;
if (this.live && rel >= 0 && rel <= end + 0.5) {
media.currentTime = rel; // keeps playing on its own
if (play) media.play().catch(() => {});
} else {
this.start(id, T, wasPlaying); // only auto-resume if it was playing
}
}
/** Play or pause. While a session is being asked for, that is the pending session's intent: the
* outgoing media is never played, only paused if the viewer asks for a pause. Returns true when
* it went into the intent — the caller then shows `want` itself, the media has not changed. */
requestPlay(want: boolean): boolean {
const media = this.deps.media();
if (!media) return false;
const pending = this.pending;
if (pending) {
pending.play = want;
pending.said = true;
if (!want && !media.paused) media.pause();
return true;
}
// The attached session's intent follows: its manifest and first canplay still play by it, and a
// pause given before they fired was played over.
if (this.attachedIntent) this.attachedIntent.play = want;
if (want) media.play().catch(() => {});
else media.pause();
return false;
}
/** Whether the viewer wants playback, for a restart that carries it over: a session still being
* asked for has its own intent; an attached one keeps the intent it was asked with until it is
* ready, because attaching empties the media — it reads paused until hls.js has parsed and played,
* and a seek or a reclaim in that window took that for the viewer's pause (the next session then
* attached paused). From ready on, the media's own state is the answer. */
private wantsPlay(media: HTMLMediaElement): boolean {
if (this.pending) return this.pending.play;
if (!this.snapshot.ready && this.attachedIntent) return this.attachedIntent.play;
return !media.paused;
}
/** What the viewer sees: the outgoing media's state, until they have said otherwise. */
shownPlaying(): boolean {
const media = this.deps.media();
const pending = this.pending;
return pending?.said ? pending.play : !!media && !media.paused;
}
/** Switch audio client-side on a multi-rendition session. False when there is none attached yet
* (the caller restarts the session with the new track instead). */
setAudioTrack(track: number): boolean {
if (!this.deps.params().multi || !this.hls) return false;
try {
this.hls.setAudioTrack(track);
} catch {
/* ignore */
}
return true;
}
}
+42
View File
@@ -0,0 +1,42 @@
// hls.js empties the cues of every text track on the <video> when a manifest loads or the media
// attaches — the Plex player's own subtitle <track> included, so a VTT that loaded first showed
// nothing for the rest of the session. Its subtitle and caption controllers are not built at all.
import { describe, expect, it, vi } from "vitest";
const built: Record<string, unknown>[] = [];
vi.mock("hls.js", () => {
class FakeHls {
static isSupported = () => true;
static Events = {};
static ErrorTypes = {};
constructor(config: Record<string, unknown>) {
built.push(config);
}
on() {}
}
return { default: FakeHls };
});
const { createHlsPort } = await import("./hlsPort");
describe("createHlsPort", () => {
it("builds hls.js without the controllers that clear every text track's cues", () => {
const noop = () => {};
createHlsPort({
onManifestParsed: noop,
onAudioTracksUpdated: noop,
onFragLoaded: noop,
onError: noop,
});
const config = built[0] ?? {};
for (const key of [
"subtitleTrackController",
"subtitleStreamController",
"timelineController",
]) {
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
expect(key in config).toBe(true);
expect(config[key]).toBeUndefined();
}
});
});
+73
View File
@@ -0,0 +1,73 @@
import Hls from "hls.js";
import type { HlsHandlers, HlsPort } from "./hlsEngine";
// The one place hls.js is constructed for the Plex player: the engine (hlsEngine.ts) sees only the
// narrow `HlsPort`, so its tests run without hls.js or a media pipeline.
export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
if (!Hls.isSupported()) return null;
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts playback
// at the live edge and hugs it, so it never builds a forward buffer (rebuffer spinners), and on a
// resume/seek session the segments' absolute PTS (-copyts, offset by the server's -ss) don't line
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
//
// No subtitle or caption controllers (a controller left unset is never built): the subtitles are
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
// the manifest showed nothing for the rest of the session.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
subtitleTrackController: undefined,
subtitleStreamController: undefined,
timelineController: undefined,
});
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
// measurement (see plexStats.ts).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
}),
);
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
type: data.type,
details: data.details,
}),
);
return {
load(url, media) {
hls.loadSource(url);
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
getAudioTrack: () => hls.audioTrack,
setAudioTrack(track) {
hls.audioTrack = track;
},
levelInfo() {
// currentLevel is -1 while auto-ABR hasn't settled; loadLevel then gives the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
return {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
},
};
}
+24 -6
View File
@@ -36,6 +36,7 @@ export type KeyScope =
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
type ShortcutWhen = "episode" | "video";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
allowInInput: true,
},
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
* sheet lives in App and cannot ask, so the surface tells it. */
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode;
const { episode, video } = conditions;
useEffect(() => {
helpConditions.set({ episode });
helpConditions.set({ episode, video });
return () => helpConditions.set({});
}, [episode]);
}, [episode, video]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// The basic keys of a player on the browser's own media element (the library's, a shared link's):
// Space/k play and pause — but Space on a focused button presses the button — the arrows step 5 s
// inside the item, m mutes, f fills the screen with a video (an audio file has no picture). While the
// element itself has the focus, Space and the arrows are its own controls' (they act on them anyway,
// so a second action from here was a double play/pause and a double step).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
const fs = vi.hoisted(() => ({ requestFullscreen: vi.fn(), exitFullscreen: vi.fn() }));
vi.mock("./fullscreen", () => ({
fullscreenElement: () => null,
requestFullscreen: fs.requestFullscreen,
exitFullscreen: fs.exitFullscreen,
}));
import { MEDIA_STEP_S, useMediaKeys } from "./mediaKeys";
afterEach(() => {
cleanup();
vi.clearAllMocks();
document.body.innerHTML = "";
});
function media(tag: "video" | "audio" = "video", duration = 100) {
const el = document.createElement(tag);
document.body.append(el);
let paused = true;
Object.defineProperty(el, "duration", { value: duration, configurable: true });
Object.defineProperty(el, "paused", { get: () => paused, configurable: true });
vi.spyOn(el, "play").mockImplementation(() => {
paused = false;
return Promise.resolve();
});
vi.spyOn(el, "pause").mockImplementation(() => {
paused = true;
});
return el;
}
function press(key: string, target: EventTarget = document.body): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
target.dispatchEvent(e);
return e;
}
describe("useMediaKeys", () => {
it("Space and k play and pause, and take the key from the page", () => {
const el = media();
renderHook(() => useMediaKeys(el));
const e = press(" ");
expect(el.play).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(true);
press("k");
expect(el.pause).toHaveBeenCalledOnce();
});
it("on the focused element, Space and the arrows are left to its own controls; k still is ours", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 50;
const space = press(" ", el);
const arrow = press("ArrowRight", el);
expect(el.play).not.toHaveBeenCalled();
expect(el.currentTime).toBe(50);
expect([space.defaultPrevented, arrow.defaultPrevented]).toEqual([false, false]);
press("k", el);
expect(el.play).toHaveBeenCalledOnce();
});
it("Space on a focused button presses the button instead", () => {
const el = media();
const button = document.createElement("button");
document.body.append(button);
renderHook(() => useMediaKeys(el));
const e = press(" ", button);
expect(el.play).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("the arrows step inside the item", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 2;
press("ArrowLeft");
expect(el.currentTime).toBe(0);
el.currentTime = 50;
press("ArrowRight");
expect(el.currentTime).toBe(50 + MEDIA_STEP_S);
el.currentTime = 98;
press("ArrowRight");
expect(el.currentTime).toBe(100);
});
it("m mutes and unmutes", () => {
const el = media();
renderHook(() => useMediaKeys(el));
press("m");
expect(el.muted).toBe(true);
press("m");
expect(el.muted).toBe(false);
});
it("f fills the screen with a video, and leaves an audio file alone", () => {
const v = media("video");
const first = renderHook(() => useMediaKeys(v));
press("f");
expect(fs.requestFullscreen).toHaveBeenCalledWith(v);
first.unmount();
const a = media("audio");
renderHook(() => useMediaKeys(a));
const e = press("f");
expect(fs.requestFullscreen).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(false);
});
it("stays out of a text field, and goes with the player", () => {
const el = media();
const input = document.createElement("input");
document.body.append(input);
const { unmount } = renderHook(() => useMediaKeys(el));
press(" ", input);
expect(el.play).not.toHaveBeenCalled();
unmount();
press(" ");
expect(el.play).not.toHaveBeenCalled();
});
});
+58
View File
@@ -0,0 +1,58 @@
// The keys of a player built on the browser's own media element — the library's and a shared link's:
// the keymap's `mediaPlayer` rows (play/pause, a 5 s step, fullscreen, mute), bound to the element.
// Its own controls do the rest; Escape stays with the dialog around it.
//
// While the element ITSELF has the focus (a click on its controls puts it there), Space and the
// arrows are its own: the browser's controls act on them whatever a listener does with the keydown,
// so ours as well played and paused on one Space, and stepped twice on one arrow (measured in Chrome).
import { useEffect, useEffectEvent } from "react";
import { dispatch, isActivationTarget, useHelpConditions, useKeyScope } from "./keymap";
import { exitFullscreen, fullscreenElement, requestFullscreen } from "./fullscreen";
export const MEDIA_STEP_S = 5;
/** Bind the `mediaPlayer` keys to `media` while it is mounted. */
export function useMediaKeys(media: HTMLMediaElement | null): void {
useKeyScope("mediaPlayer", media != null);
useHelpConditions({ video: media instanceof HTMLVideoElement });
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (!media) return;
const step = (ev: KeyboardEvent, by: number) => {
if (ev.target === media) return;
ev.preventDefault();
const end = Number.isFinite(media.duration) ? media.duration : Infinity;
media.currentTime = Math.min(end, Math.max(0, media.currentTime + by));
};
const ownKey = (ev: KeyboardEvent) => ev.target === media && ev.key !== "k";
dispatch(e, "mediaPlayer", {
playPause: (ev) => {
// Space on a focused button presses it (the dialog's ✕, a link's Download), as in every
// player here.
if (isActivationTarget(ev) || ownKey(ev)) return;
ev.preventDefault();
if (media.paused) void media.play().catch(() => {});
else media.pause();
},
seekBack: (ev) => step(ev, -MEDIA_STEP_S),
seekFwd: (ev) => step(ev, MEDIA_STEP_S),
fullscreen: (ev) => {
// An audio file has no picture to fill the screen with.
if (!(media instanceof HTMLVideoElement)) return;
ev.preventDefault();
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(media);
},
mute: (ev) => {
ev.preventDefault();
media.muted = !media.muted;
},
});
});
useEffect(() => {
if (!media) return;
const handler = (e: KeyboardEvent) => onKey(e);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [media]);
}
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// The OS's media controls: what is playing (title, line, picture), the buttons driving the player —
// through ITS controls when it has its own clock and seek (the Plex player), else the element's —
// the progress bar anchored to the player's position, and a clean hand-over: unmounting clears only
// a session that is still ours.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import { SESSION_SKIP_S, useMediaSession, type MediaSessionControls } from "./mediaSession";
type Handler = (d: MediaSessionActionDetails) => void;
let handlers: Map<string, Handler | null>;
let session: {
metadata: MediaMetadata | null;
setActionHandler: (a: string, h: Handler | null) => void;
setPositionState: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
handlers = new Map();
session = {
metadata: null,
setActionHandler: (a, h) => void handlers.set(a, h),
setPositionState: vi.fn(),
};
Object.defineProperty(navigator, "mediaSession", { value: session, configurable: true });
vi.stubGlobal(
"MediaMetadata",
class {
constructor(public init: MediaMetadataInit) {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const act = (name: string, d: Partial<MediaSessionActionDetails> = {}) =>
handlers.get(name)?.({ action: name as MediaSessionAction, ...d });
function controls(position = 100, duration = 3000): MediaSessionControls {
return {
play: vi.fn(),
pause: vi.fn(),
seekTo: vi.fn(),
position: () => position,
duration: () => duration,
};
}
describe("useMediaSession", () => {
it("tells the OS what is playing, with the picture as an absolute URL", () => {
const media = document.createElement("video");
renderHook(() =>
useMediaSession(media, { title: "Pilot", artist: "Westworld", artwork: "/api/plex/image/9" }),
);
const init = (session.metadata as unknown as { init: MediaMetadataInit }).init;
expect(init.title).toBe("Pilot");
expect(init.artist).toBe("Westworld");
expect(init.artwork?.[0]?.src).toBe(new URL("/api/plex/image/9", document.baseURI).href);
});
it("drives the player through its own controls, skips clamped to the item", () => {
const media = document.createElement("video");
const c = controls(5, 3000);
renderHook(() => useMediaSession(media, { title: "T" }, c));
act("play");
act("pause");
expect(c.play).toHaveBeenCalledOnce();
expect(c.pause).toHaveBeenCalledOnce();
act("seekbackward");
expect(c.seekTo).toHaveBeenLastCalledWith(0);
act("seekforward", { seekOffset: 30 });
expect(c.seekTo).toHaveBeenLastCalledWith(35);
act("seekto", { seekTime: 1234 });
expect(c.seekTo).toHaveBeenLastCalledWith(1234);
});
it("anchors the OS's progress bar to the player's own position, not the element's clock", () => {
const media = document.createElement("video");
renderHook(() => useMediaSession(media, { title: "T" }, controls(1800, 3000)));
media.dispatchEvent(new Event("seeked"));
expect(session.setPositionState).toHaveBeenLastCalledWith({
duration: 3000,
position: 1800,
playbackRate: 1,
});
});
it("without controls of its own, plays and seeks the element", () => {
const media = document.createElement("video");
const play = vi.spyOn(media, "play").mockResolvedValue();
Object.defineProperty(media, "duration", { value: 600, configurable: true });
renderHook(() => useMediaSession(media, { title: "T" }));
act("play");
expect(play).toHaveBeenCalledOnce();
act("seekforward");
expect(media.currentTime).toBe(SESSION_SKIP_S);
});
it("clears the session on unmount — unless another player has taken it since", () => {
const a = document.createElement("video");
const first = renderHook(() => useMediaSession(a, { title: "A" }));
first.unmount();
expect(session.metadata).toBeNull();
expect(handlers.get("play")).toBeNull();
const one = renderHook(() => useMediaSession(a, { title: "A" }));
const two = renderHook(() => useMediaSession(document.createElement("video"), { title: "B" }));
one.unmount();
expect((session.metadata as unknown as { init: MediaMetadataInit }).init.title).toBe("B");
expect(handlers.get("play")).toBeTypeOf("function");
two.unmount();
});
it("does nothing without a title or an element", () => {
renderHook(() => useMediaSession(null, { title: "T" }));
renderHook(() => useMediaSession(document.createElement("video"), null));
expect(session.metadata).toBeNull();
expect(handlers.size).toBe(0);
});
});
+161
View File
@@ -0,0 +1,161 @@
// What is playing, told to the operating system through the Media Session API: the lock screen, the
// notification shade, a headset's buttons and the keyboard's media keys show the title and the
// picture and control the player — play, pause, skip back and forward, and a seek from the OS's own
// progress bar. One definition for the Plex, library and shared-link players.
//
// The YouTube player is not one of them, by measurement: the browser routes the OS controls to the
// frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title,
// channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown.
//
// It is also what lets a browser's own "background playback" keep the sound going when the page is
// backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element
// for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of
// the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which
// STOPPED playback instead of keeping it alive (measured on iPad Brave).
import { useEffect, useEffectEvent } from "react";
export interface MediaSessionInfo {
title: string;
/** The line under the title: a channel, a show, an uploader. */
artist?: string | null;
/** A picture of it (same-origin or absolute URL). */
artwork?: string | null;
}
/** How the OS's controls drive a player whose playhead is not simply its element's (a Plex
* transcode starts its stream at the resume point, and a seek outside it restarts the stream). */
export interface MediaSessionControls {
play(): void;
pause(): void;
seekTo(seconds: number): void;
/** Where it is, in the item's own seconds. */
position(): number;
/** How long the item is; 0 while unknown. */
duration(): number;
}
/** The OS's skip back / forward step when it does not say one. */
export const SESSION_SKIP_S = 10;
/** The controls of a plain media element: its own clock, its own seek. */
function elementControls(media: HTMLMediaElement): MediaSessionControls {
return {
play: () => void media.play().catch(() => {}),
pause: () => media.pause(),
seekTo: (s) => {
media.currentTime = s;
},
position: () => media.currentTime,
duration: () => (Number.isFinite(media.duration) ? media.duration : 0),
};
}
// The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at
// the stated rate, so nothing is sent per frame.
const POSITION_EVENTS = [
"loadedmetadata",
"durationchange",
"play",
"pause",
"seeked",
"ratechange",
] as const;
const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const;
// The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the
// React Compiler skip it (see CLAUDE.md's async-handler row).
/** The metadata, told to the OS — or null when this browser refuses it (then no session at all,
* rather than half of one). */
function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null {
try {
const metadata = new MediaMetadata(init);
session.metadata = metadata;
return metadata;
} catch {
return null;
}
}
function handle(
session: MediaSession,
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
): void {
try {
session.setActionHandler(action, handler);
} catch {
/* an action this browser does not know */
}
}
function setPosition(session: MediaSession, state?: MediaPositionState): void {
try {
session.setPositionState(state);
} catch {
/* a position the browser refuses (it checks position <= duration itself), or nothing to clear */
}
}
/**
* Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the
* element's own) is what its buttons do. Cleared on unmount, unless another player has already
* claimed the session since.
*/
export function useMediaSession(
media: HTMLMediaElement | null,
info: MediaSessionInfo | null | undefined,
controls?: MediaSessionControls,
): void {
const title = info?.title ?? null;
const artist = info?.artist ?? "";
const artwork = info?.artwork ?? null;
// Read when an action arrives, not bound at the effect: the Plex player's controls are fresh
// closures every render, and re-telling the OS on each render would be churn.
const ctl = useEffectEvent(
(): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null),
);
useEffect(() => {
const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined;
if (!session || !media || title == null || typeof MediaMetadata === "undefined") return;
const metadata = claim(session, {
title,
artist,
artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [],
});
if (!metadata) return;
const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => {
const c = ctl();
if (!c) return;
const end = c.duration() || Infinity;
c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S))));
};
handle(session, "play", () => ctl()?.play());
handle(session, "pause", () => ctl()?.pause());
handle(session, "seekbackward", skip(-1));
handle(session, "seekforward", skip(1));
handle(session, "seekto", (d) => {
if (d.seekTime != null) ctl()?.seekTo(d.seekTime);
});
const anchor = () => {
const c = ctl();
const duration = c?.duration() ?? 0;
if (!c || !(duration > 0)) return;
setPosition(session, {
duration,
position: Math.min(duration, Math.max(0, c.position())),
playbackRate: media.playbackRate || 1,
});
};
for (const type of POSITION_EVENTS) media.addEventListener(type, anchor);
anchor();
return () => {
for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor);
if (session.metadata !== metadata) return; // another player has the session now
session.metadata = null;
for (const action of ACTIONS) handle(session, action, null);
setPosition(session);
};
}, [media, title, artist, artwork]);
}
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { episodeLine } from "./plexDetail";
describe("episodeLine", () => {
it("names the episode by its season and number", () => {
expect(episodeLine({ season_number: 2, episode_number: 7, title: "Pilot" })).toBe(
"S2·E7 — Pilot",
);
});
it("is the title alone for an episode Plex has no number for", () => {
expect(episodeLine({ season_number: 1, episode_number: null, title: "Special" })).toBe(
"Special",
);
expect(episodeLine({ season_number: null, episode_number: 3, title: "Special" })).toBe(
"Special",
);
});
});
+25 -3
View File
@@ -205,9 +205,31 @@ export function useCrossfadeBackdrop(
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
export function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
const n = episodeNumbers(ep);
return n ? `S${n.season} · E${n.episode}` : ep.title;
}
type Numbered = { season_number?: number | null; episode_number?: number | null };
/** An episode's season and episode number, or null when Plex has no number for one of them (a
* special). Every place that names an episode asks this, so a numberless one is named by its title
* alone everywhere: a template literal printed the missing number as "Enull", JSX as "E", and a
* `?? "?"` as "E?", which told the viewer nothing. */
export function episodeNumbers(ep: Numbered): { season: number; episode: number } | null {
if (ep.season_number == null || ep.episode_number == null) return null;
return { season: ep.season_number, episode: ep.episode_number };
}
/** An episode named in a line of its own ("Next: …", "Mark … watched"): "S1·E4", or its title. */
export function episodeName(ep: Numbered & { title: string }): string {
const n = episodeNumbers(ep);
return n ? `S${n.season}·E${n.episode}` : ep.title;
}
/** An episode's line under its show's name: "S1·E4 — Title", or the title alone (see above). */
export function episodeLine(ep: Numbered & { title: string }): string {
const n = episodeNumbers(ep);
return n ? `S${n.season}·E${n.episode} — ${ep.title}` : ep.title;
}
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
+47
View File
@@ -0,0 +1,47 @@
// Which credits block ends an item, and what skipping each marker does. An item can carry several
// credits blocks — mid- and post-credit scenes play between them — and only the last one may move
// on to the next item; only the one Plex itself flags as the end may do so on its own.
import { describe, it, expect } from "vitest";
import type { PlexMarker } from "./api";
import { markerSkip } from "./plexMarkers";
const credits = (start_s: number, end_s: number, final = false): PlexMarker => ({
type: "credits",
start_s,
end_s,
final,
});
const intro: PlexMarker = { type: "intro", start_s: 10, end_s: 60, final: false };
describe("markerSkip", () => {
it("an intro jumps to its end, on its own when the viewer wants that", () => {
expect(markerSkip(intro, [intro])).toEqual({ leaves: false, auto: true });
});
it("only the credits Plex flags final leave the item, and only they may do it on their own", () => {
const first = credits(6811, 6831);
const middle = credits(7039, 7507);
const end = credits(7549, 7559, true);
const all = [intro, first, middle, end];
expect(markerSkip(first, all)).toEqual({ leaves: false, auto: false });
expect(markerSkip(middle, all)).toEqual({ leaves: false, auto: false });
expect(markerSkip(end, all)).toEqual({ leaves: true, auto: true });
});
it("with no credits flagged final the last one is the end, but leaves only when asked", () => {
const first = credits(2000, 2100);
const last = credits(2900, 2995);
// listed out of order on purpose: "last" is by time, not by position in the list
expect(markerSkip(last, [last, first])).toEqual({ leaves: true, auto: false });
expect(markerSkip(first, [last, first])).toEqual({ leaves: false, auto: false });
expect(markerSkip(last, [last])).toEqual({ leaves: true, auto: false });
});
it("a final flag on an earlier block wins over a later block", () => {
const end = credits(2000, 2100, true);
const later = credits(2900, 2995);
expect(markerSkip(end, [end, later])).toEqual({ leaves: true, auto: true });
expect(markerSkip(later, [end, later])).toEqual({ leaves: false, auto: false });
});
});

Some files were not shown because too many files have changed in this diff Show More