Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f4efde98 | ||
|
|
c0059a0c4b | ||
|
|
a29cadf03e | ||
|
|
83886494ce | ||
|
|
4eaa55385f | ||
|
|
7541b1b016 | ||
|
|
24ad17fc7d | ||
|
|
48bd038c5f | ||
|
|
0fce732497 | ||
|
|
7e94da72b9 | ||
|
|
fadb4f17a9 | ||
|
|
57a81c5643 | ||
|
|
dae0b80f6a | ||
|
|
75093458c9 | ||
|
|
5db6dc8ee6 | ||
|
|
45a9ceae25 | ||
|
|
5249c41edc | ||
|
|
c3664826fe | ||
|
|
c9b8fe15a3 | ||
|
|
1044eaf8a9 | ||
|
|
ca949d22de | ||
|
|
20178f0ec4 | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 | ||
|
|
3865689c26 | ||
|
|
c32429ba69 | ||
|
|
a504550196 | ||
|
|
5095047c81 | ||
|
|
4e70f168b1 | ||
|
|
7f17f488ab | ||
|
|
d1704a5519 | ||
|
|
121ebeebc1 | ||
|
|
ee5290ba01 | ||
|
|
dd6a666ad1 | ||
|
|
392099770a | ||
|
|
2aa3ac6e85 | ||
|
|
891dc628f3 | ||
|
|
0a35f2a3b1 | ||
|
|
d25a132495 | ||
|
|
61d3a1a189 | ||
|
|
d482a5e8e2 | ||
|
|
2064373ac2 | ||
|
|
0cda5b6dd4 | ||
|
|
f3c94641a7 | ||
|
|
59b27467ab | ||
|
|
02feaa9a1c | ||
|
|
c1058f1d5a |
@@ -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).
|
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
|
## 0.101.0 — 2026-10-06
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -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 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 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 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 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 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])` |
|
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
- **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
|
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
||||||
to your device, share with another user, or hand out a public watch link.
|
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
|
- **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.
|
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.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -743,14 +743,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 37,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"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
@@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin):
|
|||||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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):
|
class SearchFind(Base, TimestampMixin):
|
||||||
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
"""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
|
# 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`.
|
# 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))
|
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)
|
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;
|
# 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
|
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||||
|
|||||||
@@ -167,12 +167,16 @@ class PlexClient:
|
|||||||
keys = ",".join(str(k) for k in rating_keys)
|
keys = ",".join(str(k) for k in rating_keys)
|
||||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
||||||
|
|
||||||
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
|
def create_collection(
|
||||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
|
||||||
|
) -> dict:
|
||||||
|
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
|
||||||
|
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
|
||||||
|
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
|
||||||
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||||
mc = self._write(
|
mc = self._write(
|
||||||
"POST",
|
"POST",
|
||||||
f"/library/collections?type=1&smart=0§ionId={section_key}"
|
f"/library/collections?type={item_type}&smart=0§ionId={section_key}"
|
||||||
f"&title={quote(title, safe='')}&uri={uri}",
|
f"&title={quote(title, safe='')}&uri={uri}",
|
||||||
)
|
)
|
||||||
items = mc.get("Metadata", []) or []
|
items = mc.get("Metadata", []) or []
|
||||||
|
|||||||
@@ -744,8 +744,9 @@ def start_session(
|
|||||||
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
# `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
|
# 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
|
# 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
|
# registered session is no longer `replace_only`, we abort and return None, and the caller
|
||||||
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
|
# 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
|
# 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).
|
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||||
preset, crf, max_height = "veryfast", 20, 0
|
preset, crf, max_height = "veryfast", 20, 0
|
||||||
@@ -840,12 +841,13 @@ def start_session(
|
|||||||
)
|
)
|
||||||
respawned = _spawn(False, False, replace_only=s)
|
respawned = _spawn(False, False, replace_only=s)
|
||||||
if respawned is None:
|
if respawned is None:
|
||||||
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
|
||||||
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
|
||||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
|
||||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
# Never None either: the POST /session route turns a None return into a "Media file not found
|
||||||
# "Not available yet") the client re-requests.
|
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
|
||||||
return current_session(user_id, key) or s
|
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
|
||||||
|
return s
|
||||||
s = respawned
|
s = respawned
|
||||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
if ready:
|
if ready:
|
||||||
@@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
|||||||
return dropped
|
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:
|
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
|
"""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
|
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||||
|
|||||||
@@ -9,16 +9,52 @@ one existence-leak policy.
|
|||||||
from typing import IO, Any, TypeVar
|
from typing import IO, Any, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import Base
|
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.issues import gitea
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.youtube.client import YouTubeError, refusal_reason
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
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:
|
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
|
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
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 import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.mailtext import lang_for_user
|
from app.mailtext import lang_for_user
|
||||||
@@ -39,11 +40,19 @@ from app.models import (
|
|||||||
DownloadProfile,
|
DownloadProfile,
|
||||||
DownloadQuota,
|
DownloadQuota,
|
||||||
DownloadShare,
|
DownloadShare,
|
||||||
|
LibraryState,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
User,
|
||||||
display_text,
|
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.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
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}
|
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:
|
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
|
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
||||||
every single-job handler shares."""
|
every single-job handler shares."""
|
||||||
@@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
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")
|
@router.get("/usage")
|
||||||
@@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = _assets_for(db, jobs)
|
||||||
|
positions = _positions_for(db, user, jobs)
|
||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
data = _serialize(j, assets.get(j.asset_id))
|
data = _serialize(j, assets.get(j.asset_id))
|
||||||
data["shared"] = True
|
data["shared"] = True
|
||||||
|
data["position_seconds"] = positions.get(j.id, 0)
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -825,6 +856,39 @@ def download_file(
|
|||||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
# --- 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")
|
@router.get("/{job_id}/storyboard")
|
||||||
def storyboard_meta(
|
def storyboard_meta(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
|||||||
+11
-34
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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 import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,14 +26,19 @@ from app.feedquery import (
|
|||||||
windowed_relevance_rows,
|
windowed_relevance_rows,
|
||||||
)
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
POSITION_MAX_S,
|
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
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.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
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.
|
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||||
VALID_STATES = {"new", "watched", "hidden"}
|
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.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
def _feed_params(
|
def _feed_params(
|
||||||
@@ -326,23 +323,10 @@ def clear_video_state(
|
|||||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
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")
|
@router.post("/videos/{video_id}/progress")
|
||||||
def set_video_progress(
|
def set_video_progress(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
body: VideoProgressIn,
|
body: ProgressIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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
|
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.
|
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."""
|
Trivially-early and near-finished positions clear the position rather than store it."""
|
||||||
position = int(body.position_seconds)
|
position = kept_position(body)
|
||||||
duration = int(body.duration_seconds)
|
|
||||||
if position < 0:
|
|
||||||
position = 0
|
|
||||||
|
|
||||||
get_or_404(db, Video, video_id, what="video")
|
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(
|
row = db.execute(
|
||||||
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if not keep:
|
if not position:
|
||||||
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
||||||
# zero the position (a saved/hidden video keeps its status).
|
# zero the position (a saved/hidden video keeps its status).
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
|||||||
@@ -176,6 +176,40 @@ def collections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections/for/{item_rating_key}")
|
||||||
|
def collections_for_item(
|
||||||
|
item_rating_key: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
|
||||||
|
library (a collection lives in one library, and only a manual one can take a title — the same
|
||||||
|
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
|
||||||
|
movie = by_rating_key(db, PlexItem, item_rating_key)
|
||||||
|
title: PlexItem | PlexShow | None = (
|
||||||
|
movie if movie is not None and movie.kind == "movie" else None
|
||||||
|
) or by_rating_key(db, PlexShow, item_rating_key)
|
||||||
|
if title is None:
|
||||||
|
raise not_found("item")
|
||||||
|
member_of = set(title.collection_keys or [])
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexCollection)
|
||||||
|
.where(PlexCollection.library_id == title.library_id)
|
||||||
|
.order_by(collated(PlexCollection.title, lang_for_user(user)))
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"collections": [
|
||||||
|
{**_collection_card(c), "contains": c.rating_key in member_of}
|
||||||
|
for c in rows
|
||||||
|
if _collection_editable(c)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Member(NamedTuple):
|
class Member(NamedTuple):
|
||||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||||
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||||
@@ -407,27 +441,47 @@ def create_collection(
|
|||||||
seed = body.item_rating_key
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise refused(400, "collection_fields_required")
|
raise refused(400, "collection_fields_required")
|
||||||
|
seed_show = by_rating_key(db, PlexShow, seed)
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# The "New collection" flow only knows the seed title → derive its library.
|
# The "New collection" flow only knows the seed title → derive its library.
|
||||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
|
||||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise not_found("library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
|
new_rk = ""
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
new = plex.create_collection(lib.plex_key, title, seed)
|
# A show collection is made as one (Plex type 2): as a movie one it stayed empty.
|
||||||
|
new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1)
|
||||||
wrote = True
|
wrote = True
|
||||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
new_rk = str(new.get("ratingKey") or "")
|
||||||
|
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
||||||
col.editable = True
|
col.editable = True
|
||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
if wrote and new_rk:
|
||||||
|
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
||||||
|
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
||||||
|
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
|
||||||
|
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
|
||||||
|
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
|
||||||
|
# the collection it was just added to, and the collection empty, until that sync.
|
||||||
|
db.add(
|
||||||
|
PlexCollection(
|
||||||
|
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||||
|
if row := by_rating_key(db, Model, seed):
|
||||||
|
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
|
||||||
|
db.commit()
|
||||||
raise _write_or_pending_error(wrote, e) from e
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|||||||
@@ -404,6 +404,10 @@ def item_detail(
|
|||||||
"type": m.get("type"),
|
"type": m.get("type"),
|
||||||
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||||
"end_s": round((m.get("endTimeOffset") 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).
|
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import (
|
|||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
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.auth import current_user, resolved_user_id
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
POSITION_MAX_S,
|
|
||||||
PlexItem,
|
PlexItem,
|
||||||
PlexSeason,
|
PlexSeason,
|
||||||
PlexShow,
|
PlexShow,
|
||||||
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
|
|||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.plex._shared import item_or_404
|
from app.routes.plex._shared import item_or_404
|
||||||
from app.refusal import refused
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -160,11 +159,11 @@ def subtitle(
|
|||||||
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
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
|
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
|
||||||
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
|
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
|
||||||
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
|
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
|
||||||
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
|
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
|
||||||
# not drift. Keep them separate on purpose (see routes/feed.py).
|
# Keep them separate on purpose (see routes/_common.py).
|
||||||
_PROGRESS_MIN_S = 5
|
_PROGRESS_MIN_S = 5
|
||||||
|
|
||||||
_FINISH_MARGIN_S = 30
|
_FINISH_MARGIN_S = 30
|
||||||
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ItemProgressIn(BaseModel):
|
class ItemProgressIn(ProgressIn):
|
||||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
|
||||||
# one into a 500.
|
|
||||||
model_config = ConfigDict(allow_inf_nan=False)
|
|
||||||
|
|
||||||
# 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
|
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
|
# 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.
|
# 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}",
|
"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,
|
"transcode": s.transcode,
|
||||||
"hw": s.hw,
|
"hw": s.hw,
|
||||||
"tonemap": s.tonemap,
|
"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")
|
@router.get("/stream/{rating_key}/file")
|
||||||
def stream_file(
|
def stream_file(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -159,6 +159,10 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex):
|
|||||||
assert plex.seen[-1].method == "POST"
|
assert plex.seen[-1].method == "POST"
|
||||||
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
||||||
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
|
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
|
||||||
|
assert params["type"] == "1" # a movie collection unless told otherwise
|
||||||
|
# A show collection is made as one: made as a movie one, it came out empty in a TV section.
|
||||||
|
plex.create_collection("3", "Shows", "42", 2)
|
||||||
|
assert plex.seen[-1].url.params["type"] == "2"
|
||||||
|
|
||||||
plex.routes["/library/collections"] = {}
|
plex.routes["/library/collections"] = {}
|
||||||
with pytest.raises(PlexError):
|
with pytest.raises(PlexError):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||||
from app.plex.client import PlexError
|
from app.plex.client import PlexError
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex import collections as coll_routes
|
from app.routes.plex import collections as coll_routes
|
||||||
|
|
||||||
|
|
||||||
@@ -224,8 +226,9 @@ class _CollStub:
|
|||||||
if self.fail_on == name:
|
if self.fail_on == name:
|
||||||
raise PlexError("boom")
|
raise PlexError("boom")
|
||||||
|
|
||||||
def create_collection(self, section_key, title, item_rating_key):
|
def create_collection(self, section_key, title, item_rating_key, item_type=1):
|
||||||
self._maybe("create_collection")
|
self._maybe("create_collection")
|
||||||
|
self.created_type = item_type
|
||||||
return {"ratingKey": "NEW"}
|
return {"ratingKey": "NEW"}
|
||||||
|
|
||||||
def add_collection_item(self, rk, item):
|
def add_collection_item(self, rk, item):
|
||||||
@@ -320,6 +323,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
|
|||||||
assert body["editable"] is True # create marks the new collection editable
|
assert body["editable"] is True # create marks the new collection editable
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)])
|
||||||
|
def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type):
|
||||||
|
# Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out
|
||||||
|
# empty with a show seed, and refused the show when it was added after.
|
||||||
|
lib, user = _seed_editable(db)
|
||||||
|
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series"))
|
||||||
|
db.commit()
|
||||||
|
stub = _CollStub()
|
||||||
|
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
|
||||||
|
r = client.post(
|
||||||
|
"/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert stub.created_type == plex_type
|
||||||
|
|
||||||
|
|
||||||
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
||||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||||
@@ -337,6 +356,19 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
|||||||
assert r.status_code == 422, r.text
|
assert r.status_code == 422, r.text
|
||||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||||
assert "create_collection" in stub.calls
|
assert "create_collection" in stub.calls
|
||||||
|
# It exists on Plex: its row stays, editable, until the next sync fills it in — so it is offered
|
||||||
|
# where collections are added to, and the same name is not made twice.
|
||||||
|
row = by_rating_key(db, PlexCollection, "NEW")
|
||||||
|
assert row is not None and row.editable and row.title == "Fresh Coll"
|
||||||
|
# Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it.
|
||||||
|
assert row.child_count == 1
|
||||||
|
assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or [])
|
||||||
|
again = client.post(
|
||||||
|
"/api/plex/collections",
|
||||||
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
|
)
|
||||||
|
assert again.status_code == 409, again.text
|
||||||
|
assert stub.calls.count("create_collection") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
|
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the
|
||||||
|
title is in it already. DB lane (`db` fixture)."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.state as state
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add_all([movies, shows])
|
||||||
|
db.flush()
|
||||||
|
enriched = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexItem(
|
||||||
|
rating_key="m1",
|
||||||
|
title="Film",
|
||||||
|
kind="movie",
|
||||||
|
library_id=movies.id,
|
||||||
|
collection_keys=["cA"],
|
||||||
|
people_enriched_at=enriched,
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="e1",
|
||||||
|
title="An episode",
|
||||||
|
kind="episode",
|
||||||
|
library_id=shows.id,
|
||||||
|
people_enriched_at=enriched,
|
||||||
|
),
|
||||||
|
PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]),
|
||||||
|
# The movie library's: two manual ones it can go into, and three it cannot.
|
||||||
|
PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True),
|
||||||
|
PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True),
|
||||||
|
PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False),
|
||||||
|
PlexCollection(
|
||||||
|
rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True
|
||||||
|
),
|
||||||
|
PlexCollection(
|
||||||
|
rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True
|
||||||
|
),
|
||||||
|
# The show library's own.
|
||||||
|
PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
user = User(email="collfor@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _get(db, monkeypatch, override_dep, rk):
|
||||||
|
user = _seed(db)
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
override_dep(get_db, db.info["request"])
|
||||||
|
override_dep(current_user, lambda: user)
|
||||||
|
return TestClient(app).get(f"/api/plex/collections/for/{rk}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
resp = _get(db, monkeypatch, override_dep, "m1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
got = [(c["id"], c["contains"]) for c in resp.json()["collections"]]
|
||||||
|
assert got == [("cA", True), ("cB", False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep):
|
||||||
|
resp = _get(db, monkeypatch, override_dep, "s1")
|
||||||
|
assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_episode_is_not_a_collection_member(db, monkeypatch, override_dep):
|
||||||
|
assert _get(db, monkeypatch, override_dep, "e1").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_key_is_not_found(db, monkeypatch, override_dep):
|
||||||
|
assert _get(db, monkeypatch, override_dep, "nope").status_code == 404
|
||||||
@@ -401,3 +401,38 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title(
|
|||||||
("magyar Forced", "hu", True, False, None),
|
("magyar Forced", "hu", True, False, None),
|
||||||
("English SDH", "en", False, True, 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["mode"] == "hls"
|
||||||
assert body["transcode"] is False
|
assert body["transcode"] is False
|
||||||
assert captured["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):
|
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -695,7 +695,9 @@ class TestTranscodeStartSession:
|
|||||||
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
|
# 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
|
# 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.
|
# 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(
|
monkeypatch.setattr(
|
||||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
)
|
)
|
||||||
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
|
|||||||
|
|
||||||
newer = _Newer()
|
newer = _Newer()
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
def wait_then_supersede(path, timeout=20.0, proc=None):
|
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||||
# (now-killed) HW proc produced nothing.
|
# (now-killed) HW proc produced nothing.
|
||||||
|
captured["s"] = stream._sessions[(7, "12345")]
|
||||||
stream._sessions[(7, "12345")] = newer
|
stream._sessions[(7, "12345")] = newer
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
|
|||||||
)
|
)
|
||||||
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||||
assert len(_CapturingProc.calls) == 1
|
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
|
assert stream._sessions[(7, "12345")] is newer
|
||||||
|
|
||||||
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
|
|||||||
return TestClient(app)
|
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"])
|
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
||||||
def test_a_non_finite_position_is_refused(client, path, raw):
|
def test_a_non_finite_position_is_refused(client, path, raw):
|
||||||
r = client.post(
|
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
|
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"])
|
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
||||||
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
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."""
|
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
||||||
from app.routes.downloads import AdminSetQuotaIn
|
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.setup import SetupAdminIn
|
||||||
from app.routes.tags import TagUpdateIn
|
from app.routes.tags import TagUpdateIn
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
|
|||||||
def test_progress_accepts_fractional_seconds():
|
def test_progress_accepts_fractional_seconds():
|
||||||
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
# 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.
|
# handler) — a plain `int` field would 422 on the progress hot path.
|
||||||
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
|
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
|
||||||
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
|
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
|
||||||
with pytest.raises(ValidationError):
|
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". ---
|
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
|
||||||
|
|||||||
+11
-2
@@ -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
|
**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.
|
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
|
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
|
- **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
|
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||||
live). [WCAG 1.4.13]
|
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
|
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 ✕;
|
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,
|
`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
|
- **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
|
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
|
`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-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||||
|
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||||
|
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
||||||
|
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
||||||
|
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||||
|
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||||
|
|||||||
@@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { ListPlus, Plus } from "lucide-react";
|
import { ListPlus } from "lucide-react";
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||||
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
|
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||||
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
|
|
||||||
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
|
||||||
export default function AddToPlaylist({
|
export default function AddToPlaylist({
|
||||||
videoId,
|
videoId,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
@@ -94,9 +90,7 @@ function MembershipPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const open = pop.open;
|
const open = pop.open;
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const membership = useQuery({
|
const membership = useQuery({
|
||||||
@@ -115,13 +109,17 @@ function MembershipPanel({
|
|||||||
|
|
||||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||||
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
||||||
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
|
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that
|
||||||
|
// landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it.
|
||||||
async function toggle(pl: Playlist) {
|
async function toggle(pl: Playlist) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||||
else await api.addToPlaylist(pl.id, videoId);
|
else await api.addToPlaylist(pl.id, videoId);
|
||||||
|
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
|
||||||
|
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||||
}
|
}
|
||||||
@@ -129,19 +127,20 @@ function MembershipPanel({
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd() {
|
async function createAndAdd(name: string) {
|
||||||
const name = newName.trim();
|
if (busy) return false;
|
||||||
if (!name || busy) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let made = false;
|
||||||
try {
|
try {
|
||||||
const pl = await api.createPlaylist(name);
|
const pl = await api.createPlaylist(name);
|
||||||
await api.addToPlaylist(pl.id, videoId);
|
await api.addToPlaylist(pl.id, videoId);
|
||||||
setNewName("");
|
made = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("playlists.addFailed"));
|
notifyActionError(e, t("playlists.addFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
return made;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
||||||
@@ -149,67 +148,40 @@ function MembershipPanel({
|
|||||||
const lists = membership.data ?? [];
|
const lists = membership.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PopoverSurface
|
||||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
popover={pop}
|
||||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
className={`${SURFACE.popover} w-60 p-2`}
|
||||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
ariaLabel={t("playlists.addToPlaylistTitle")}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
listInside
|
||||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
>
|
||||||
{t("playlists.addToPlaylistTitle")}
|
<MembershipMenu
|
||||||
</div>
|
heading={t("playlists.addToPlaylistTitle")}
|
||||||
<div
|
rows={lists.map((pl) => ({
|
||||||
ref={fadeRef}
|
id: pl.id,
|
||||||
style={fadeStyle}
|
label: playlistName(pl, t),
|
||||||
aria-busy={busy}
|
checked: !!pl.has_video,
|
||||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
extra:
|
||||||
>
|
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||||
{lists.length === 0 && !membership.isLoading && (
|
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
||||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
) : undefined,
|
||||||
)}
|
}))}
|
||||||
{lists.map((pl) => (
|
onToggle={(row) => {
|
||||||
<PopoverItem
|
const pl = lists.find((p) => p.id === row.id);
|
||||||
key={pl.id}
|
if (pl) void toggle(pl);
|
||||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
}}
|
||||||
role="menuitemcheckbox"
|
status={
|
||||||
checked={pl.has_video}
|
membership.isError && !membership.data
|
||||||
onSelect={() => toggle(pl)}
|
? "error"
|
||||||
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
: membership.isLoading
|
||||||
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
? "loading"
|
||||||
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
: "ready"
|
||||||
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
}
|
||||||
label={playlistName(pl, t)}
|
onRetry={() => void membership.refetch()}
|
||||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
emptyText={t("playlists.noneYet")}
|
||||||
>
|
errorText={t("common.loadError")}
|
||||||
<MenuCheck kind="many" on={!!pl.has_video} />
|
busy={busy}
|
||||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
|
||||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
/>
|
||||||
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
</PopoverSurface>
|
||||||
)}
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
|
||||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
|
||||||
<Input
|
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Escape bubbles to the popover's dismiss; every other key is text editing here and
|
|
||||||
// must not reach the surrounding menu's list navigation.
|
|
||||||
if (e.key === "Escape") return;
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
void createAndAdd();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t("playlists.newPlaylist")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverSurface>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
|
||||||
|
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
|
||||||
|
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the channel tag editor", () => {
|
||||||
|
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
|
||||||
|
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<FloatingTree>
|
||||||
|
<ChannelTagsControl
|
||||||
|
userTags={[]}
|
||||||
|
attachedIds={[]}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onCreate={() => {}}
|
||||||
|
/>
|
||||||
|
</FloatingTree>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
|
||||||
|
const alert = await screen.findByRole("alert", { hidden: true });
|
||||||
|
expect(alert.textContent).toContain("common.loadError");
|
||||||
|
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
|
||||||
|
expect(alert.closest("[role=menu]")).toBeNull();
|
||||||
|
expect(await screen.findByText("common.retry")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Plus, Sparkles } from "lucide-react";
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../lib/api";
|
||||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { IconButton } from "./ui/IconButton";
|
|
||||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||||
import { chipClass } from "./ui/chipStyles";
|
import { chipClass } from "./ui/chipStyles";
|
||||||
import { Input } from "./ui/Input";
|
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||||
|
import { useTags } from "../lib/useTags";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||||
@@ -194,8 +192,9 @@ function SuggestedChipsLane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||||
* detail page in Part 2; internal for now.) */
|
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
|
||||||
|
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
|
||||||
function TagEditorMenu({
|
function TagEditorMenu({
|
||||||
userTags,
|
userTags,
|
||||||
attachedIds,
|
attachedIds,
|
||||||
@@ -208,71 +207,27 @@ function TagEditorMenu({
|
|||||||
onCreate: (name: string) => void;
|
onCreate: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState("");
|
const tags = useTags();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const attached = new Set(attachedIds);
|
const attached = new Set(attachedIds);
|
||||||
const submit = () => {
|
|
||||||
const name = draft.trim();
|
|
||||||
if (!name) return;
|
|
||||||
onCreate(name);
|
|
||||||
setDraft("");
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<MembershipMenu
|
||||||
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
|
dense
|
||||||
{userTags.map((tg) => {
|
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||||
const on = attached.has(tg.id);
|
onToggle={(row) => onToggle(row.id as number)}
|
||||||
return (
|
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||||
<PopoverItem
|
onRetry={() => void tags.refetch()}
|
||||||
key={tg.id}
|
emptyText={t("channels.tags.noneYet")}
|
||||||
// Several tags can be on at once — a checkbox set, not one exclusive choice.
|
errorText={t("common.loadError")}
|
||||||
role="menuitemcheckbox"
|
create={{
|
||||||
checked={on}
|
placeholder: t("channels.tags.createPlaceholder"),
|
||||||
onSelect={() => onToggle(tg.id)}
|
maxLength: 64,
|
||||||
label={tg.name}
|
submitLabel: t("channels.tags.create"),
|
||||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
onCreate: (name) => {
|
||||||
on ? "text-fg" : "text-muted"
|
onCreate(name);
|
||||||
}`}
|
return true;
|
||||||
>
|
},
|
||||||
<MenuCheck kind="many" on={on} />
|
}}
|
||||||
<span className="truncate">{tg.name}</span>
|
/>
|
||||||
</PopoverItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{userTags.length === 0 && (
|
|
||||||
<p className="text-2xs text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
|
|
||||||
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
|
|
||||||
// list-navigation/typeahead handlers on the surrounding surface.
|
|
||||||
if (e.key === "Escape") return;
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxLength={64}
|
|
||||||
placeholder={t("channels.tags.createPlaceholder")}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="outline"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={!draft.trim()}
|
|
||||||
label={t("channels.tags.create")}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={editPop}
|
popover={editPop}
|
||||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||||
|
listInside
|
||||||
>
|
>
|
||||||
<TagEditorMenu
|
<TagEditorMenu
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
|
|||||||
@@ -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 () => {
|
it("copies the share link from the row's own Share button", async () => {
|
||||||
mocks.jobs = [job(2, "done")];
|
mocks.jobs = [job(2, "done")];
|
||||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
|
|||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = 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 [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
|
|||||||
<IconBtn
|
<IconBtn
|
||||||
open
|
open
|
||||||
onClick={() => setPlayJob(job)}
|
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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
|
|||||||
<IconBtn
|
<IconBtn
|
||||||
open
|
open
|
||||||
onClick={() => setPlayJob(job)}
|
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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
|
|||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(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)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function useHomeCardMenu({
|
|||||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.plex
|
: target.plex
|
||||||
? plexActions
|
? plexActions
|
||||||
.items(target.plex, { play: false, pin: false })
|
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
|
||||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.kind === "channel" || target.kind === "playlist"
|
: target.kind === "channel" || target.kind === "playlist"
|
||||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||||
// is key-passthrough here.
|
// is key-passthrough here.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
|
|||||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||||
|
|
||||||
import LibraryPlayer from "./LibraryPlayer";
|
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 = {
|
const baseJob: DownloadJob = {
|
||||||
id: 7,
|
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", () => {
|
describe("LibraryPlayer", () => {
|
||||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
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();
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
import { useMediaLogRef } from "../lib/mediaLog";
|
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
|
// 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
|
// 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
|
// 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
|
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||||
// any codec (best-effort playback).
|
// 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 { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
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 —
|
// 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
|
// 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.
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
const logMedia = useMediaLogRef("library");
|
const logMedia = useMediaLogRef("library");
|
||||||
const videoRef = useCallback(
|
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
||||||
(el: HTMLVideoElement | null) => {
|
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||||
exposeVideoToBackgroundPlay(el);
|
const mediaRef = useCallback(
|
||||||
|
(el: HTMLMediaElement | null) => {
|
||||||
|
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
||||||
logMedia(el);
|
logMedia(el);
|
||||||
|
setMedia(el);
|
||||||
},
|
},
|
||||||
[logMedia],
|
[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 (
|
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 ? (
|
{isAudio ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-2">
|
<div className="flex flex-col items-center gap-4 py-2">
|
||||||
{poster ? (
|
{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"
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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")}
|
{t("downloads.player.unsupported")}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={mediaRef}
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
|
|||||||
|
|
||||||
import { qk } from "../lib/queryKeys";
|
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.
|
// The provider reads the signed-in account (the tab title) from the `me` cache.
|
||||||
let qc = new QueryClient();
|
let qc = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
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", () => {
|
describe("NavigationProvider · never under an open overlay", () => {
|
||||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
|
|||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
import {
|
import {
|
||||||
|
backStaysInDocument,
|
||||||
closeOverlaysThenNavigate,
|
closeOverlaysThenNavigate,
|
||||||
nextNavId,
|
nextNavId,
|
||||||
notifyNav,
|
notifyNav,
|
||||||
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeChannel = useCallback(() => {
|
const closeChannel = useCallback(() => {
|
||||||
if (window.history.state?._chan) window.history.back();
|
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
|
||||||
else {
|
else {
|
||||||
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||||
// drop the ?chan param from the address bar.
|
// 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);
|
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(() => {
|
const exitYtSearch = useCallback(() => {
|
||||||
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||||
if (window.history.state?._yt) window.history.back();
|
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||||
else setYtSearch(null);
|
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.
|
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function useCap(): string {
|
|||||||
// words matched nothing.
|
// words matched nothing.
|
||||||
|
|
||||||
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
||||||
* too; left out of a phone's narrow header, where there is no keyboard to press it on. */
|
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
|
||||||
|
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
|
||||||
export function PaletteKeycap() {
|
export function PaletteKeycap() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cap = useCap();
|
const cap = useCap();
|
||||||
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
|
|||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label={`${t("palette.open")} (${cap})`}
|
aria-label={`${t("palette.open")} (${cap})`}
|
||||||
title={`${t("palette.open")} (${cap})`}
|
title={`${t("palette.open")} (${cap})`}
|
||||||
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
|
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
|
||||||
>
|
>
|
||||||
{cap}
|
{cap}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -444,6 +444,72 @@ describe("PlayerModal — the side arrows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
|
||||||
|
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
|
||||||
|
// top. A cutout given only as a share of the height lay over that button on a short player (a small
|
||||||
|
// window, a browser zoomed to 150 %): the click paused instead.
|
||||||
|
describe("PlayerModal — the click layer over the video", () => {
|
||||||
|
const layers = () => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
return screen.getAllByTestId("yt-click-layer");
|
||||||
|
};
|
||||||
|
const box = (el: HTMLElement) => {
|
||||||
|
const { left, right, width, top, bottom } = el.style;
|
||||||
|
return { left, right, width, top, bottom };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
|
||||||
|
const [picture, column] = layers();
|
||||||
|
expect(box(picture!)).toEqual({
|
||||||
|
left: "0px",
|
||||||
|
right: "88px",
|
||||||
|
width: "",
|
||||||
|
top: "max(12%, 56px)",
|
||||||
|
bottom: "max(22%, 104px)",
|
||||||
|
});
|
||||||
|
expect(box(column!)).toEqual({
|
||||||
|
left: "",
|
||||||
|
right: "0px",
|
||||||
|
width: "88px",
|
||||||
|
top: "max(12%, 56px)",
|
||||||
|
bottom: "max(22%, 140px)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
|
||||||
|
// bottom, so a box measured from the stage's would start that bar's height too low again.
|
||||||
|
it.each([
|
||||||
|
["the windowed card", false, /(^|\s)relative(\s|$)/],
|
||||||
|
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
|
||||||
|
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
|
||||||
|
const els = layers();
|
||||||
|
const stage = document.querySelector(".player-stage")!;
|
||||||
|
if (fullscreen) {
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => stage,
|
||||||
|
});
|
||||||
|
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const el of screen.getAllByTestId("yt-click-layer")) {
|
||||||
|
const wrapper = el.parentElement!;
|
||||||
|
expect(wrapper).not.toBe(stage);
|
||||||
|
expect(wrapper.parentElement).toBe(stage);
|
||||||
|
expect(wrapper.className).toMatch(inset);
|
||||||
|
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
|
||||||
|
}
|
||||||
|
expect(els).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
|
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
|
||||||
// row. The card's width is capped from the window height less what sits under the video, measured.
|
// row. The card's width is capped from the window height less what sits under the video, measured.
|
||||||
describe("PlayerModal — fits the window's height", () => {
|
describe("PlayerModal — fits the window's height", () => {
|
||||||
@@ -485,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
}) as unknown as MediaQueryList,
|
}) as unknown as MediaQueryList,
|
||||||
);
|
);
|
||||||
|
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
|
||||||
|
// every later test's media query would read that bare stub.
|
||||||
|
let added = false;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
|
if (typeof window.matchMedia === "function") return;
|
||||||
|
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
|
||||||
|
added = true;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
// The spy goes first: restoring it later would put the bare stub back.
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
|
||||||
|
added = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
|
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
|
||||||
@@ -591,3 +668,40 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(screen.queryByTestId("player-description")).toBeNull();
|
expect(screen.queryByTestId("player-description")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
|
||||||
|
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
|
||||||
|
describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||||
|
const maximised = (api_: boolean) => {
|
||||||
|
if (api_)
|
||||||
|
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
|
||||||
|
else
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => true,
|
||||||
|
});
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("player.fullscreen"));
|
||||||
|
return screen.getAllByLabelText("player.fullscreen").length;
|
||||||
|
};
|
||||||
|
afterEach(() => {
|
||||||
|
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||||
|
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is offered where the browser can fullscreen an element", () => {
|
||||||
|
expect(maximised(true)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is left out where it cannot", () => {
|
||||||
|
expect(maximised(false)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -56,7 +57,7 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
@@ -106,6 +107,35 @@ const WIDE_PLAYER_PX = 1280;
|
|||||||
const SIDE_PANEL = "20rem";
|
const SIDE_PANEL = "20rem";
|
||||||
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
||||||
const MIN_VIDEO = "36rem";
|
const MIN_VIDEO = "36rem";
|
||||||
|
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
|
||||||
|
// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the
|
||||||
|
// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button
|
||||||
|
// above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never
|
||||||
|
// shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the
|
||||||
|
// layer lies over that button and a click on it pauses instead. Only the right-hand column needs the
|
||||||
|
// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the
|
||||||
|
// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any
|
||||||
|
// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap.
|
||||||
|
// Px, not rem: the text-size slider does not grow YouTube's controls.
|
||||||
|
const YT_TOP_CONTROLS_PX = 56;
|
||||||
|
const YT_SEEK_BAR_PX = 104;
|
||||||
|
const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 };
|
||||||
|
// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the
|
||||||
|
// column above that button.
|
||||||
|
const YT_CLICK_LAYER: CSSProperties[] = [
|
||||||
|
{
|
||||||
|
left: 0,
|
||||||
|
right: YT_FULLSCREEN_BUTTON_PX.fromRight,
|
||||||
|
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
|
||||||
|
bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
right: 0,
|
||||||
|
width: YT_FULLSCREEN_BUTTON_PX.fromRight,
|
||||||
|
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
|
||||||
|
bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
|
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
|
||||||
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
|
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
|
||||||
@@ -115,6 +145,9 @@ const metaSep = (
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||||
|
// Beside the channel name the meta takes what is left of the line, but never less than this: under it
|
||||||
|
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
|
||||||
|
const META_MIN = "min-w-48 flex-1";
|
||||||
|
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
@@ -689,39 +722,48 @@ export default function PlayerModal({
|
|||||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
|
maximized || isFullscreen
|
||||||
|
? "absolute inset-x-0 top-0 bottom-14"
|
||||||
|
: "relative w-full h-full"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={mountRef}
|
ref={mountRef}
|
||||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
||||||
|
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
||||||
|
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
||||||
|
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
|
||||||
|
measured against the iframe it sits in (in the big views that excludes our docked bar)
|
||||||
|
and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden
|
||||||
|
from assistive tech: the keyboard has the shortcuts it stands for.
|
||||||
|
Armed in the big views too (that is what keeps wheel volume working after F, the reported
|
||||||
|
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
||||||
|
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
||||||
|
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||||
|
{playerError == null &&
|
||||||
|
YT_CLICK_LAYER.map((box, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid="yt-click-layer"
|
||||||
|
onClick={() => {
|
||||||
|
focusModal();
|
||||||
|
togglePlay();
|
||||||
|
}}
|
||||||
|
title={t("player.shortcutsHint")}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={box}
|
||||||
|
className={`absolute z-base cursor-pointer ${
|
||||||
|
nativeControls ? "pointer-events-none" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
|
||||||
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
|
||||||
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
|
||||||
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
|
|
||||||
Armed in the big views too (that is what keeps wheel volume working after F, the reported
|
|
||||||
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
|
||||||
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
|
||||||
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
|
||||||
{playerError == null && (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
focusModal();
|
|
||||||
togglePlay();
|
|
||||||
}}
|
|
||||||
title={t("player.shortcutsHint")}
|
|
||||||
aria-label={t("player.shortcutsHint")}
|
|
||||||
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
|
|
||||||
nativeControls ? "pointer-events-none" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
||||||
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
||||||
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
||||||
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
||||||
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
||||||
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
|
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
|
||||||
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||||
@@ -771,13 +813,18 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px bg-white/20" />
|
||||||
<IconButton
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||||
variant="overlay"
|
the window-filling view is the biggest, and YouTube's own button opens the
|
||||||
onClick={toggleTrueFullscreen}
|
system player. */}
|
||||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
{canFullscreenElements() && (
|
||||||
>
|
<IconButton
|
||||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
variant="overlay"
|
||||||
</IconButton>
|
onClick={toggleTrueFullscreen}
|
||||||
|
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -922,10 +969,13 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
{/* Channel + meta on one line, with the actions pushed to the right.
|
||||||
When navigated to a linked video we only have its author (from the player),
|
When navigated to a linked video we only have its author (from the player),
|
||||||
so we show that as plain text and hide feed-video-specific bits. */}
|
so we show that as plain text and hide feed-video-specific bits.
|
||||||
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
|
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
|
||||||
|
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
|
||||||
|
one-word column between the name and the buttons. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={active.channel_thumbnail}
|
src={active.channel_thumbnail}
|
||||||
@@ -973,7 +1023,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||||
>
|
>
|
||||||
{active.view_count != null && (
|
{active.view_count != null && (
|
||||||
<span>
|
<span>
|
||||||
@@ -1004,7 +1054,7 @@ export default function PlayerModal({
|
|||||||
) : (
|
) : (
|
||||||
// Linked video's stats come from the (already-fetched) video detail.
|
// Linked video's stats come from the (already-fetched) video detail.
|
||||||
<div
|
<div
|
||||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||||
>
|
>
|
||||||
{detail.data?.view_count != null && (
|
{detail.data?.view_count != null && (
|
||||||
<span>
|
<span>
|
||||||
@@ -1037,51 +1087,48 @@ export default function PlayerModal({
|
|||||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||||
the docked bar offers OUR true fullscreen. */}
|
the docked bar offers OUR true fullscreen. */}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<IconButton
|
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||||
variant="outline"
|
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||||
size="md"
|
<IconButton
|
||||||
onClick={toggleFullscreen}
|
variant="outline"
|
||||||
label={t("player.fullscreen")}
|
size="md"
|
||||||
title={`${t("player.fullscreen")} · F`}
|
onClick={toggleFullscreen}
|
||||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
label={t("player.fullscreen")}
|
||||||
className={wide ? undefined : "ml-auto"}
|
title={`${t("player.fullscreen")} · F`}
|
||||||
>
|
>
|
||||||
<Maximize />
|
<Maximize />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
<DownloadButton
|
||||||
{!navigated && (
|
videoId={active.id}
|
||||||
<DownloadButton
|
title={active.title}
|
||||||
videoId={active.id}
|
variant="outline"
|
||||||
title={active.title}
|
size="md"
|
||||||
variant="outline"
|
/>
|
||||||
size="md"
|
{onRemoveFromList && (
|
||||||
/>
|
<IconButton
|
||||||
)}
|
variant="outline-danger"
|
||||||
{!navigated && onRemoveFromList && (
|
size="md"
|
||||||
<IconButton
|
onClick={() => void removeFromList()}
|
||||||
variant="outline-danger"
|
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||||
size="md"
|
// Download / Mark watched reads as "delete this video".
|
||||||
onClick={() => void removeFromList()}
|
label={t("playlists.removeItemOf", { title: active.title })}
|
||||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
title={t("playlists.removeItem")}
|
||||||
// Download / Mark watched reads as "delete this video".
|
>
|
||||||
label={t("playlists.removeItemOf", { title: active.title })}
|
<Trash2 />
|
||||||
title={t("playlists.removeItem")}
|
</IconButton>
|
||||||
>
|
)}
|
||||||
<Trash2 />
|
<Button
|
||||||
</IconButton>
|
variant="outline"
|
||||||
)}
|
size="sm"
|
||||||
{!navigated && (
|
pressed={watched}
|
||||||
<Button
|
onClick={() => setWatched(!watched)}
|
||||||
variant="outline"
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
size="sm"
|
icon={watched ? <CheckCheck /> : <Check />}
|
||||||
pressed={watched}
|
>
|
||||||
onClick={() => setWatched(!watched)}
|
{watched ? t("player.watched") : t("player.markWatched")}
|
||||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
</Button>
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
</div>
|
||||||
>
|
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
{wide && (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||||
@@ -157,6 +157,64 @@ describe("the add-to-playlist popover", () => {
|
|||||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
|
||||||
|
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client()}>
|
||||||
|
<AddToPlaylist videoId="v1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||||
|
const alert = await screen.findByRole("alert", { hidden: true });
|
||||||
|
expect(alert.textContent).toContain("common.loadError");
|
||||||
|
expect(screen.queryByText("playlists.noneYet")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opened from a card's menu, is a menu named for itself, its lines beside the list", async () => {
|
||||||
|
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
||||||
|
const anchor = document.createElement("button");
|
||||||
|
document.body.append(anchor);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client()}>
|
||||||
|
<AddToPlaylistAt videoId="v1" anchor={anchor} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
// The menu row that opened it is gone: its own name, not a label pointing at that row. jsdom
|
||||||
|
// computes no name for the unpositioned surface, so read what names it.
|
||||||
|
const menu = await waitFor(() => {
|
||||||
|
const found = document.querySelector(
|
||||||
|
'[role="menu"][aria-label="playlists.addToPlaylistTitle"]',
|
||||||
|
);
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
return found!;
|
||||||
|
});
|
||||||
|
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
|
||||||
|
const alert = await screen.findByRole("alert", { hidden: true });
|
||||||
|
expect(alert.closest("[role=menu]")).toBeNull();
|
||||||
|
anchor.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => {
|
||||||
|
const lists = vi.spyOn(api, "playlists");
|
||||||
|
lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never);
|
||||||
|
// The refetch after the write never answers: the tick must not wait for it.
|
||||||
|
lists.mockImplementation(() => new Promise(() => {}));
|
||||||
|
const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never);
|
||||||
|
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client()}>
|
||||||
|
<AddToPlaylist videoId="v1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||||
|
const row = await screen.findByRole("menuitemcheckbox", { hidden: true });
|
||||||
|
fireEvent.click(row);
|
||||||
|
await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true"));
|
||||||
|
fireEvent.click(row);
|
||||||
|
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1"));
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
|
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
|
||||||
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
|
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
|
||||||
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
|
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { api, HttpError, type PlexCollection } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
|
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
|
||||||
|
// into or out of its library's manual collections, and names a new one — created with this title in
|
||||||
|
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
|
||||||
|
// collection, its name, a search for the title, then another search to add the next one. Its body is
|
||||||
|
// the same MembershipMenu as the video card's "Add to playlist".
|
||||||
|
|
||||||
|
export interface PlexCollectionTarget {
|
||||||
|
ratingKey: string;
|
||||||
|
anchor: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlexCollectionAddAt({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: PlexCollectionTarget;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const pop = useAnchoredPopover({
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (next) => {
|
||||||
|
if (!next) onClose();
|
||||||
|
},
|
||||||
|
offset: 6,
|
||||||
|
hostsInput: true,
|
||||||
|
});
|
||||||
|
const { setPositionReference } = pop.refs;
|
||||||
|
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const { ratingKey } = target;
|
||||||
|
const forKey = qk.plexCollections("for", ratingKey);
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: forKey,
|
||||||
|
queryFn: () => api.plexCollectionsFor(ratingKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
|
||||||
|
// all show membership.
|
||||||
|
function refresh() {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
|
||||||
|
// before its refetch returned would otherwise repeat the write instead of undoing it.
|
||||||
|
async function toggle(c: PlexCollection & { contains: boolean }) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
|
||||||
|
else await api.plexCollectionAddItem(c.id, ratingKey);
|
||||||
|
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.collection.editFailed"));
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(name: string) {
|
||||||
|
if (busy) return false;
|
||||||
|
setBusy(true);
|
||||||
|
let made = false;
|
||||||
|
try {
|
||||||
|
// No library: the server takes the seed title's own.
|
||||||
|
await api.plexCreateCollection("", name, ratingKey);
|
||||||
|
made = true;
|
||||||
|
} catch (e) {
|
||||||
|
made = madeOnPlex(e);
|
||||||
|
notifyActionError(e, t("plex.collection.editFailed"));
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
setBusy(false);
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = list.data?.collections ?? [];
|
||||||
|
return (
|
||||||
|
<PopoverSurface
|
||||||
|
popover={pop}
|
||||||
|
className={`${SURFACE.popover} w-64 p-2`}
|
||||||
|
ariaLabel={t("plex.collectionAdd.title")}
|
||||||
|
listInside
|
||||||
|
>
|
||||||
|
<MembershipMenu
|
||||||
|
heading={t("plex.collectionAdd.title")}
|
||||||
|
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
|
||||||
|
onToggle={(row) => {
|
||||||
|
const c = collections.find((x) => x.id === row.id);
|
||||||
|
if (c) void toggle(c);
|
||||||
|
}}
|
||||||
|
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
|
||||||
|
onRetry={() => void list.refetch()}
|
||||||
|
emptyText={t("plex.collectionAdd.none")}
|
||||||
|
errorText={t("plex.collectionAdd.loadFailed")}
|
||||||
|
busy={busy}
|
||||||
|
create={{
|
||||||
|
placeholder: t("plex.collection.newCollection"),
|
||||||
|
ariaLabel: t("plex.collection.namePlaceholder"),
|
||||||
|
onCreate: create,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A create refused only because the read after it failed: the collection is on Plex, with this
|
||||||
|
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
|
||||||
|
function madeOnPlex(e: unknown): boolean {
|
||||||
|
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
|
||||||
|
return d?.reason === "collection_pending_sync";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
|
||||||
|
old: T | undefined,
|
||||||
|
id: string,
|
||||||
|
): T | undefined {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
|||||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||||
import {
|
import {
|
||||||
DETAIL_POSTER,
|
DETAIL_POSTER,
|
||||||
|
episodeLine,
|
||||||
tmdbBackdropUrl,
|
tmdbBackdropUrl,
|
||||||
useCrossfadeBackdrop,
|
useCrossfadeBackdrop,
|
||||||
useDetailAside,
|
useDetailAside,
|
||||||
@@ -315,7 +316,7 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
{detail.kind === "episode" && (
|
{detail.kind === "episode" && (
|
||||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
{episodeLine(detail)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { FloatingTree } from "@floating-ui/react";
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { PlexCard } from "../lib/api";
|
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -101,21 +102,27 @@ function Grid({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
function setup(
|
||||||
|
c: PlexCard,
|
||||||
|
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
|
||||||
|
{ admin = false }: { admin?: boolean } = {},
|
||||||
|
) {
|
||||||
const handlers = {
|
const handlers = {
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
onInfo: vi.fn(),
|
onInfo: vi.fn(),
|
||||||
onToggleWatched: vi.fn(),
|
onToggleWatched: vi.fn(),
|
||||||
onRestart: vi.fn(),
|
onRestart: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Grid c={c} handlers={handlers} {...opts} />
|
<Grid c={c} handlers={handlers} {...opts} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { handlers };
|
return { handlers, qc };
|
||||||
}
|
}
|
||||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||||
const rightClick = () =>
|
const rightClick = () =>
|
||||||
@@ -223,3 +230,189 @@ describe("a Plex title's menu", () => {
|
|||||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
|
||||||
|
// search for the title. From the card's menu it is a tick, or a name and Enter.
|
||||||
|
describe("a Plex title's menu — Add to collection", () => {
|
||||||
|
const coll = (id: string, title: string, contains: boolean) =>
|
||||||
|
({ id, title, contains }) as PlexCollection & { contains: boolean };
|
||||||
|
|
||||||
|
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).toContain("plex.collectionAdd.row");
|
||||||
|
cleanup();
|
||||||
|
setup(card({ type: "show" }), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).toContain("plex.collectionAdd.row");
|
||||||
|
cleanup();
|
||||||
|
setup(card({ type: "episode" }), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||||
|
cleanup();
|
||||||
|
setup(card({}));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
|
||||||
|
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||||
|
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
|
||||||
|
});
|
||||||
|
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||||
|
const remove = vi
|
||||||
|
.spyOn(api, "plexCollectionRemoveItem")
|
||||||
|
.mockResolvedValue({} as PlexCollection);
|
||||||
|
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
const row = async (name: string) =>
|
||||||
|
vi.waitFor(() => {
|
||||||
|
const found = screen
|
||||||
|
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||||
|
.find((b) => b.textContent === name);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
return found!;
|
||||||
|
});
|
||||||
|
fireEvent.click(await row("Beta"));
|
||||||
|
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||||
|
// One write at a time: the next tick waits for this one to settle.
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||||
|
);
|
||||||
|
fireEvent.click(await row("Alpha"));
|
||||||
|
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
|
||||||
|
const name = screen.getByLabelText("plex.collection.namePlaceholder");
|
||||||
|
fireEvent.change(name, { target: { value: " Rainy days " } });
|
||||||
|
fireEvent.keyDown(name, { key: "Enter" });
|
||||||
|
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkRow = async (name: string) =>
|
||||||
|
vi.waitFor(() => {
|
||||||
|
const found = screen
|
||||||
|
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||||
|
.find((b) => b.textContent === name);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
return found!;
|
||||||
|
});
|
||||||
|
const idle = () =>
|
||||||
|
vi.waitFor(() =>
|
||||||
|
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
|
||||||
|
const list = vi.spyOn(api, "plexCollectionsFor");
|
||||||
|
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
|
||||||
|
// The refetch after the write never answers: the tick must not wait for it.
|
||||||
|
list.mockImplementation(() => new Promise(() => {}));
|
||||||
|
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||||
|
const remove = vi
|
||||||
|
.spyOn(api, "plexCollectionRemoveItem")
|
||||||
|
.mockResolvedValue({} as PlexCollection);
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
fireEvent.click(await checkRow("Beta"));
|
||||||
|
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||||
|
await idle();
|
||||||
|
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
|
||||||
|
fireEvent.click(await checkRow("Beta"));
|
||||||
|
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes a show's own page too, whose collections come with the show", async () => {
|
||||||
|
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||||
|
collections: [coll("cB", "Beta", false)],
|
||||||
|
});
|
||||||
|
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||||
|
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
|
||||||
|
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
fireEvent.click(await checkRow("Beta"));
|
||||||
|
await idle();
|
||||||
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a named menu that says it is loading, not that there are no collections", async () => {
|
||||||
|
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
|
||||||
|
const menu = await vi.waitFor(() => {
|
||||||
|
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
return found!;
|
||||||
|
});
|
||||||
|
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
|
||||||
|
expect(screen.getByText("common.loading")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a failed load as one, with a retry — not as no collections", async () => {
|
||||||
|
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
const alert = await screen.findByRole("alert", { hidden: true });
|
||||||
|
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
|
||||||
|
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||||
|
// A menu owns its items only: the line about the list sits beside it, not in it.
|
||||||
|
expect(document.querySelector("[role=menu]")).not.toBeNull();
|
||||||
|
expect(alert.closest("[role=menu]")).toBeNull();
|
||||||
|
list.mockResolvedValue({
|
||||||
|
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
|
||||||
|
});
|
||||||
|
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
|
||||||
|
// on <body> with the menu open — it lands on the first row that came back.
|
||||||
|
const retry = (await screen.findByText("common.retry")).closest("button")!;
|
||||||
|
retry.focus();
|
||||||
|
fireEvent.click(retry);
|
||||||
|
const beta = await checkRow("Beta");
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
|
||||||
|
// …and the menu's own arrows carry on from there.
|
||||||
|
fireEvent.keyDown(beta, { key: "ArrowDown" });
|
||||||
|
expect(document.activeElement).toBe(await checkRow("Gamma"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the name when Plex made the collection and only the read after it failed", async () => {
|
||||||
|
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
|
||||||
|
const create = vi
|
||||||
|
.spyOn(api, "plexCreateCollection")
|
||||||
|
.mockRejectedValue(
|
||||||
|
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
|
||||||
|
);
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
|
||||||
|
fireEvent.change(name, { target: { value: "Noir" } });
|
||||||
|
fireEvent.keyDown(name, { key: "Enter" });
|
||||||
|
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||||
|
// It exists now: a second Enter on the kept name could only be refused as taken.
|
||||||
|
await vi.waitFor(() => expect(name.value).toBe(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
|
||||||
|
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
|
||||||
|
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
|
||||||
|
() => new Promise((resolve) => (answer = resolve)),
|
||||||
|
);
|
||||||
|
setup(card({}), {}, { admin: true });
|
||||||
|
rightClick();
|
||||||
|
fireEvent.click(item("plex.collectionAdd.row"));
|
||||||
|
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
|
||||||
|
name.focus();
|
||||||
|
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
|
||||||
|
const alpha = await checkRow("Alpha");
|
||||||
|
fireEvent.keyDown(name, { key: "ArrowDown" });
|
||||||
|
expect(document.activeElement).toBe(alpha);
|
||||||
|
name.focus();
|
||||||
|
fireEvent.keyDown(name, { key: "ArrowUp" });
|
||||||
|
expect(document.activeElement).toBe(await checkRow("Beta"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function PlexMenu({
|
|||||||
<ContextMenu
|
<ContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
ariaLabel={t("plex.menu")}
|
ariaLabel={t("plex.menu")}
|
||||||
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
|
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
|
||||||
/>
|
/>
|
||||||
{actions.panel}
|
{actions.panel}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 () => {
|
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
|
||||||
const intro = {
|
const intro = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
mount({ details: { A: intro } });
|
mount({ details: { A: intro } });
|
||||||
await playingAt("/media/A", 100);
|
await playingAt("/media/A", 100);
|
||||||
@@ -857,3 +857,73 @@ describe("the seek bar (C3)", () => {
|
|||||||
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the time in the row", () => {
|
||||||
|
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
|
||||||
|
expect(row.textContent).not.toMatch(/-\s*48:20/);
|
||||||
|
expect(row.textContent).toMatch(/\(.+\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
|
||||||
|
// own background under the player.
|
||||||
|
describe("the page under the player", () => {
|
||||||
|
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
expect(root.overscrollBehavior).toBe("none");
|
||||||
|
expect(root.backgroundColor).toBe("black");
|
||||||
|
cleanup();
|
||||||
|
expect(document.body.style.overflow).toBe("");
|
||||||
|
expect(root.overscrollBehavior).toBe("");
|
||||||
|
expect(root.backgroundColor).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is covered by a player sized from its fixed box, not from dvh", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
|
||||||
|
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
|
||||||
|
// nothing there. It is shown only where the browser has the API on Element.prototype.
|
||||||
|
describe("the fullscreen button", () => {
|
||||||
|
const withApi = (on: boolean) => {
|
||||||
|
if (on)
|
||||||
|
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||||
|
};
|
||||||
|
afterEach(() => withApi(false));
|
||||||
|
|
||||||
|
it("is there where the browser can fullscreen an element", async () => {
|
||||||
|
withApi(true);
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
|
||||||
|
withApi(false);
|
||||||
|
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
|
||||||
|
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
|
||||||
|
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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")));
|
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 () => {
|
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||||
prefs({ upNext: false });
|
prefs({ upNext: false });
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
@@ -334,7 +373,7 @@ describe("leaving an item that was played through", () => {
|
|||||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||||
const withCredits = {
|
const withCredits = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
details: { A: withCredits, B: item("B") },
|
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 });
|
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||||
const withCredits = {
|
const withCredits = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
details: { A: withCredits, B: item("B") },
|
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");
|
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 () => {
|
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
|
// `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
|
// 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 },
|
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||||
],
|
],
|
||||||
} as unknown as PlexItemDetail;
|
} 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]));
|
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||||
fireEvent.keyDown(window, { key: "a" });
|
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" });
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||||
|
await act(async () => answers[1]!.resolve(direct("A")));
|
||||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
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]);
|
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);
|
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: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
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 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();
|
await tick();
|
||||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
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 () => {
|
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||||
mount({
|
mount({
|
||||||
details: { A: item("A", 1200), B: item("B") },
|
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();
|
await tick();
|
||||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
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";
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
|
||||||
|
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
|
||||||
|
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => Promise.resolve(),
|
||||||
|
});
|
||||||
class Stub {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
|
|||||||
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 PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import { api, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
|
|||||||
thumb: "",
|
thumb: "",
|
||||||
}) as unknown as PlexCard;
|
}) as unknown as PlexCard;
|
||||||
|
|
||||||
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void) {
|
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
|
||||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||||
const onPlay = vi.fn();
|
const onPlay = vi.fn();
|
||||||
const onBack = vi.fn();
|
const onBack = vi.fn();
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) client.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider
|
<QueryClientProvider client={client}>
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
||||||
>
|
|
||||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PlexPlaylistView · a row's menu", () => {
|
describe("PlexPlaylistView · a row's menu", () => {
|
||||||
|
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
|
||||||
|
mount([movie("1")], undefined, { admin: true });
|
||||||
|
await screen.findByText("Movie 1");
|
||||||
|
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||||
|
expect(menuRows()).toContain("plex.collectionAdd.row");
|
||||||
|
});
|
||||||
|
|
||||||
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
|
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
|
||||||
mount([movie("1"), ep("e1", 1, 1)]);
|
mount([movie("1"), ep("e1", 1, 1)]);
|
||||||
await screen.findByText("Movie 1");
|
await screen.findByText("Movie 1");
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
|
|||||||
? []
|
? []
|
||||||
: menuTarget.kind === "item"
|
: menuTarget.kind === "item"
|
||||||
? [
|
? [
|
||||||
...titleActions.items(menuTarget.item),
|
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
|
||||||
...orderRows(menuTarget),
|
...orderRows(menuTarget),
|
||||||
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type RefObject, useEffect, useState } from "react";
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type Hls from "hls.js";
|
|
||||||
import { X } from "lucide-react";
|
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";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
|
// "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({
|
export function PlexStatsOverlay({
|
||||||
videoRef,
|
videoRef,
|
||||||
hlsRef,
|
engine,
|
||||||
fragMeterRef,
|
fragMeterRef,
|
||||||
audioLabel,
|
audioLabel,
|
||||||
subLabel,
|
subLabel,
|
||||||
playable,
|
playable,
|
||||||
hwRef,
|
|
||||||
tonemapRef,
|
|
||||||
sessionStartRef,
|
|
||||||
absRef,
|
absRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
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
|
// 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.
|
// long as this panel) — the source of the MEASURED bitrate row.
|
||||||
fragMeterRef: RefObject<FragMeter>;
|
fragMeterRef: RefObject<FragMeter>;
|
||||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||||
playable: string;
|
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>;
|
absRef: RefObject<number>;
|
||||||
durationRef: RefObject<number>;
|
durationRef: RefObject<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
const hls = hlsRef.current;
|
|
||||||
|
|
||||||
const { dropped, total } = frameCounts(v);
|
const { dropped, total } = frameCounts(v);
|
||||||
|
|
||||||
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let level: HlsLevelInfo | null = null;
|
const level = engine.levelInfo();
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
buildStatRows({
|
buildStatRows({
|
||||||
lang,
|
lang,
|
||||||
playable,
|
playable,
|
||||||
hwEncoder: hwRef.current,
|
hwEncoder: engine.hw,
|
||||||
tonemap: tonemapRef.current,
|
tonemap: engine.tonemap,
|
||||||
transport: hls ? "hls" : "direct",
|
transport: level ? "hls" : "direct",
|
||||||
videoWidth: v?.videoWidth ?? 0,
|
videoWidth: v?.videoWidth ?? 0,
|
||||||
videoHeight: v?.videoHeight ?? 0,
|
videoHeight: v?.videoHeight ?? 0,
|
||||||
droppedFrames: dropped,
|
droppedFrames: dropped,
|
||||||
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
|
|||||||
measuredBps: fragMeterRef.current?.bitrate(),
|
measuredBps: fragMeterRef.current?.bitrate(),
|
||||||
audioLabel,
|
audioLabel,
|
||||||
subLabel,
|
subLabel,
|
||||||
sessionStartS: sessionStartRef.current ?? 0,
|
sessionStartS: engine.sessionStart.current,
|
||||||
abs: absRef.current ?? 0,
|
abs: absRef.current ?? 0,
|
||||||
duration: durationRef.current ?? 0,
|
duration: durationRef.current ?? 0,
|
||||||
}),
|
}),
|
||||||
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
|
|||||||
sample();
|
sample();
|
||||||
const iv = setInterval(sample, 1000);
|
const iv = setInterval(sample, 1000);
|
||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, [
|
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
|
||||||
lang,
|
|
||||||
videoRef,
|
|
||||||
hlsRef,
|
|
||||||
fragMeterRef,
|
|
||||||
audioLabel,
|
|
||||||
subLabel,
|
|
||||||
playable,
|
|
||||||
hwRef,
|
|
||||||
tonemapRef,
|
|
||||||
sessionStartRef,
|
|
||||||
absRef,
|
|
||||||
durationRef,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import { subShadow } from "../lib/plexPlayerPrefs";
|
||||||
|
import { ownTextTracks } from "../lib/plexSubTracks";
|
||||||
|
|
||||||
|
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
|
||||||
|
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
||||||
|
// this layer shows them. The browser's own rendering could not move out of the way of the control
|
||||||
|
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
|
||||||
|
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
|
||||||
|
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
|
||||||
|
// settings style it directly.
|
||||||
|
|
||||||
|
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
|
||||||
|
|
||||||
|
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
|
||||||
|
const CUE_EM_OF_HEIGHT = 0.05;
|
||||||
|
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
|
||||||
|
const CLEARANCE_PX = 8;
|
||||||
|
|
||||||
|
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
|
||||||
|
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
||||||
|
const [cues, setCues] = useState<Cue[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!video) return;
|
||||||
|
const tracks = video.textTracks;
|
||||||
|
const read = () => {
|
||||||
|
const now: Cue[] = [];
|
||||||
|
const own = ownTextTracks(video);
|
||||||
|
for (const tr of Array.from(tracks)) {
|
||||||
|
if (!own.has(tr) || tr.mode === "disabled") continue;
|
||||||
|
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
||||||
|
}
|
||||||
|
setCues((prev) =>
|
||||||
|
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const bound = new Set<TextTrack>();
|
||||||
|
const bind = (tr: TextTrack) => {
|
||||||
|
if (bound.has(tr)) return;
|
||||||
|
bound.add(tr);
|
||||||
|
tr.addEventListener("cuechange", read);
|
||||||
|
};
|
||||||
|
const onAdd = () => {
|
||||||
|
for (const tr of Array.from(tracks)) bind(tr);
|
||||||
|
read();
|
||||||
|
};
|
||||||
|
for (const tr of Array.from(tracks)) bind(tr);
|
||||||
|
tracks.addEventListener("addtrack", onAdd);
|
||||||
|
tracks.addEventListener("removetrack", read);
|
||||||
|
read();
|
||||||
|
return () => {
|
||||||
|
tracks.removeEventListener("addtrack", onAdd);
|
||||||
|
tracks.removeEventListener("removetrack", read);
|
||||||
|
for (const tr of bound) tr.removeEventListener("cuechange", read);
|
||||||
|
};
|
||||||
|
}, [video]);
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The video box's height, followed as the window or fullscreen changes it. */
|
||||||
|
function useHeight(el: HTMLElement | null): number {
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setHeight(el.clientHeight);
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [el]);
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
|
||||||
|
* markup from a string, so nothing in a subtitle file can run. */
|
||||||
|
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
|
||||||
|
else el.textContent = cue.text ?? "";
|
||||||
|
}, [cue]);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlexSubtitles({
|
||||||
|
video,
|
||||||
|
pos,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
background,
|
||||||
|
shadowPx,
|
||||||
|
shadowColor,
|
||||||
|
avoid,
|
||||||
|
avoidRef,
|
||||||
|
}: {
|
||||||
|
video: HTMLVideoElement | null;
|
||||||
|
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
|
||||||
|
pos: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
background: string;
|
||||||
|
shadowPx: number;
|
||||||
|
shadowColor: string;
|
||||||
|
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
|
||||||
|
avoid: boolean;
|
||||||
|
avoidRef: RefObject<HTMLElement | null>;
|
||||||
|
}) {
|
||||||
|
const cues = useActiveCues(video);
|
||||||
|
const height = useHeight(video);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
|
||||||
|
// not slid there from where the previous cue was left.
|
||||||
|
const [lift, setLift] = useState({ px: 0, slide: false });
|
||||||
|
const shownRef = useRef(false);
|
||||||
|
|
||||||
|
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
|
||||||
|
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
|
||||||
|
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
|
||||||
|
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
|
||||||
|
// by that scale.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const box = boxRef.current;
|
||||||
|
if (!box || !video || cues.length === 0) {
|
||||||
|
shownRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = box.getBoundingClientRect();
|
||||||
|
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
|
||||||
|
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
|
||||||
|
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
||||||
|
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
|
||||||
|
setLift({ px, slide: shownRef.current });
|
||||||
|
shownRef.current = true;
|
||||||
|
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
||||||
|
|
||||||
|
if (cues.length === 0 || height === 0) return null;
|
||||||
|
const text: React.CSSProperties = {
|
||||||
|
color,
|
||||||
|
backgroundColor: background,
|
||||||
|
textShadow: subShadow(shadowPx, shadowColor),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={boxRef}
|
||||||
|
data-testid="plex-subtitles"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
|
||||||
|
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="plex-subtitles-text"
|
||||||
|
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
|
||||||
|
style={{ transform: `translateY(${-lift.px}px)` }}
|
||||||
|
>
|
||||||
|
{cues.map((cue, i) => (
|
||||||
|
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
|
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
|
||||||
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
|
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
|
||||||
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
|
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("SearchBar responsive wrapper", () => {
|
describe("SearchBar responsive wrapper", () => {
|
||||||
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
||||||
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||||
const cls = (container.firstElementChild as HTMLElement).className;
|
const cls = (container.firstElementChild as HTMLElement).className;
|
||||||
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
|
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
|
||||||
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
|
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
|
||||||
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
|
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
|
||||||
expect(cls).toContain("@max-[640px]/appmain:basis-full");
|
expect(cls).toContain("@max-[640px]/appmain:basis-full");
|
||||||
expect(cls).toContain("@max-[640px]/appmain:order-last");
|
expect(cls).toContain("@max-[640px]/appmain:order-last");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
|
||||||
|
describe("SearchBar on a touch screen", () => {
|
||||||
|
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
|
||||||
|
const { container, getByRole } = render(
|
||||||
|
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
|
||||||
|
"pointer-coarse:hidden",
|
||||||
|
);
|
||||||
|
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
|
||||||
|
expect(pill.className).toContain("pointer-coarse:h-13");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortens Go to its icon by the column's width, not the screen's", () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<SearchBar
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Search…"
|
||||||
|
onGo={() => {}}
|
||||||
|
goIcon={<svg />}
|
||||||
|
goLabel="Go far"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const label = getByText("Go far");
|
||||||
|
expect(label.className).toContain("@min-[900px]/appmain:inline");
|
||||||
|
expect(label.className).not.toMatch(/(^|\s)md:inline/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,14 +72,18 @@ export default function SearchBar({
|
|||||||
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
|
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
|
||||||
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
|
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
|
||||||
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
|
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
|
||||||
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
|
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
|
||||||
|
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
|
||||||
|
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
|
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
|
||||||
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
|
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
|
||||||
|
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
|
||||||
|
44 pill sat on its edge. */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
fieldClass({ size: "free", pill: true, floating: true }),
|
fieldClass({ size: "free", pill: true, floating: true }),
|
||||||
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
|
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||||
@@ -116,7 +120,8 @@ export default function SearchBar({
|
|||||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||||
>
|
>
|
||||||
{goIcon}
|
{goIcon}
|
||||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
|
||||||
|
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
|
||||||
{goLabel ?? t("header.go")}
|
{goLabel ?? t("header.go")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
|
|||||||
expect(onAdvance).not.toHaveBeenCalled();
|
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({
|
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "e7",
|
id: "e7",
|
||||||
kind: "episode",
|
kind: "episode",
|
||||||
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
|
|||||||
episode_number: null,
|
episode_number: null,
|
||||||
});
|
});
|
||||||
renderCard();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from "../lib/api";
|
|||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { cardImage } from "../lib/plexImage";
|
import { cardImage } from "../lib/plexImage";
|
||||||
|
import { episodeNumbers } from "../lib/plexDetail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
@@ -55,10 +56,14 @@ export function UpNextCard({
|
|||||||
const R = 10;
|
const R = 10;
|
||||||
const C = 2 * Math.PI * R;
|
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 =
|
const heading =
|
||||||
next?.kind === "episode" && next.show_title
|
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 ?? "");
|
: (next?.title ?? "");
|
||||||
const sub = next?.kind === "episode" ? next.title : null;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { Chip } from "./ui/Chip";
|
import { Chip } from "./ui/Chip";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
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 { LS, readJSON, writeJSON } from "../lib/storage";
|
||||||
import { send } from "../lib/http";
|
import { send } from "../lib/http";
|
||||||
import { Input } from "./ui/Input";
|
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
|
// 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).
|
// 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 = {
|
type Meta = {
|
||||||
needs_password: boolean;
|
needs_password: boolean;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -95,13 +64,24 @@ export default function WatchPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const errorId = useId();
|
const errorId = useId();
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||||
// both without a per-element typed ref.
|
// the resume bind to it once it exists.
|
||||||
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||||
const setMedia = (el: HTMLMediaElement | null) => {
|
useMediaSession(
|
||||||
mediaRef.current = el;
|
status === "ready" ? media : null,
|
||||||
};
|
meta && {
|
||||||
useMediaSession(mediaRef, meta, status === "ready");
|
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
|
// 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.
|
// (this page has no account); an unreadable storage just means the default.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
|
|||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
||||||
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
import { MediaProgress } from "./ui/MediaCard";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className={THUMB_BOX}>
|
<div className={`${THUMB_BOX} relative`}>
|
||||||
|
<MediaProgress pct={pct} />
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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(playGlyph(container)).not.toBeNull();
|
||||||
expect(screen.getByText("plex.nextUp")).toBeTruthy();
|
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
|
||||||
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
||||||
// The episode's own add ("Add episode"), not the show's.
|
// The episode's own add ("Add episode"), not the show's.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
|
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
|
// 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.
|
// proxy's small cut too and lets the browser pick by its width.
|
||||||
describe("a Plex card's picture", () => {
|
describe("a Plex card's picture", () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react";
|
|||||||
import { formatDecimal, formatRuntime } from "../lib/format";
|
import { formatDecimal, formatRuntime } from "../lib/format";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
import { seasonTitle } from "../lib/plexDetail";
|
import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||||
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
||||||
@@ -318,7 +318,8 @@ export function PlexPosterCard({
|
|||||||
const m = more(card, menuButtonProps, menuOpen);
|
const m = more(card, menuButtonProps, menuOpen);
|
||||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
||||||
const add = isPlayable && onAdd ? () => onAdd(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 show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
||||||
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||||
const corner =
|
const corner =
|
||||||
@@ -361,8 +362,10 @@ export function PlexPosterCard({
|
|||||||
<HoverBar
|
<HoverBar
|
||||||
watched={actedWatched}
|
watched={actedWatched}
|
||||||
watchedLabel={
|
watchedLabel={
|
||||||
epCode
|
nextName
|
||||||
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
|
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", {
|
||||||
|
name: nextName,
|
||||||
|
})
|
||||||
: actedWatched
|
: actedWatched
|
||||||
? t("plex.markUnwatched")
|
? t("plex.markUnwatched")
|
||||||
: t("plex.markWatched")
|
: t("plex.markWatched")
|
||||||
@@ -389,14 +392,14 @@ export function PlexPosterCard({
|
|||||||
meta={
|
meta={
|
||||||
asShow ? (
|
asShow ? (
|
||||||
<div className="truncate text-xs leading-6 text-muted">
|
<div className="truncate text-xs leading-6 text-muted">
|
||||||
{t("plex.episodeLine", {
|
{episodeNumbers(card)
|
||||||
season: card.season_number ?? "?",
|
? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title })
|
||||||
episode: card.episode_number ?? "?",
|
: card.title}
|
||||||
title: card.title,
|
</div>
|
||||||
})}
|
) : nextName ? (
|
||||||
|
<div className="truncate text-xs leading-6 text-muted">
|
||||||
|
{t("plex.nextUp", { name: nextName })}
|
||||||
</div>
|
</div>
|
||||||
) : epCode ? (
|
|
||||||
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
|
|
||||||
) : (
|
) : (
|
||||||
(!!card.year || card.rating != null) && (
|
(!!card.year || card.rating != null) && (
|
||||||
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
||||||
@@ -741,8 +744,8 @@ export function EpisodeCard({
|
|||||||
ep.show_title && (
|
ep.show_title && (
|
||||||
<div className="truncate text-xs text-muted">
|
<div className="truncate text-xs text-muted">
|
||||||
{ep.show_title}
|
{ep.show_title}
|
||||||
{ep.season_number != null && ep.episode_number != null
|
{episodeNumbers(ep)
|
||||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
type KeyScope,
|
type KeyScope,
|
||||||
} from "../../lib/keymap";
|
} from "../../lib/keymap";
|
||||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
|
import { useMediaKeys } from "../../lib/mediaKeys";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
|
|||||||
pressH();
|
pressH();
|
||||||
expect(descriptions()).toContain("keys.episode");
|
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();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
/** 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
|
* 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;
|
* 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 ✕. */
|
* 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
|
// 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.
|
// 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
|
// 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.
|
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
|
||||||
initial: () =>
|
initial: () =>
|
||||||
|
(initialFocus === "player" ? panelRef.current : null) ??
|
||||||
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
|
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
|
||||||
(closeRef.current?.offsetParent != null ? closeRef.current : null),
|
(closeRef.current?.offsetParent != null ? closeRef.current : null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
|
|
||||||
| area | components |
|
| area | components |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
|
|||||||
@@ -64,3 +64,41 @@ describe("an open tooltip", () => {
|
|||||||
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
|
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On an iPad a tap's synthetic mouseenter showed the caption; WebKit then took the tap as a hover and
|
||||||
|
// swallowed its click (every rail button needed two taps), and with no mouseleave the caption stayed.
|
||||||
|
describe("a tooltip under a finger", () => {
|
||||||
|
class PointerEventStub extends MouseEvent {
|
||||||
|
pointerType: string;
|
||||||
|
constructor(type: string, init: PointerEventInit = {}) {
|
||||||
|
super(type, init);
|
||||||
|
this.pointerType = init.pointerType ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hover = (trigger: HTMLElement, pointerType: string) =>
|
||||||
|
act(() => {
|
||||||
|
trigger.dispatchEvent(new PointerEventStub("pointerdown", { bubbles: true, pointerType }));
|
||||||
|
trigger.dispatchEvent(new PointerEventStub("pointerenter", { pointerType }));
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
fireEvent.mouseMove(trigger);
|
||||||
|
});
|
||||||
|
const mount = () => {
|
||||||
|
render(
|
||||||
|
<Tooltip hint="A hint" always>
|
||||||
|
<button>icon</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
return screen.getByText("icon").parentElement as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("does not open from a tap", async () => {
|
||||||
|
hover(mount(), "touch");
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still opens under a mouse", async () => {
|
||||||
|
hover(mount(), "mouse");
|
||||||
|
expect(await screen.findByRole("tooltip")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default function Tooltip({
|
|||||||
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a
|
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a
|
||||||
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
|
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
|
||||||
hoverDelay: { open: openDelay, close: 0 },
|
hoverDelay: { open: openDelay, close: 0 },
|
||||||
|
// A caption is for a mouse (and a key's focus). Opened by a tap it cost touch the tap itself.
|
||||||
|
mouseHoverOnly: true,
|
||||||
});
|
});
|
||||||
const { setReference } = pop.refs;
|
const { setReference } = pop.refs;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions {
|
|||||||
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
|
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
|
||||||
* and a tap is the card's play. */
|
* and a tap is the card's play. */
|
||||||
pointerOnly?: boolean;
|
pointerOnly?: boolean;
|
||||||
|
/** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a
|
||||||
|
* control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that
|
||||||
|
* first tap as a hover and swallowed its click (every rail button needed two taps), and with no
|
||||||
|
* mouseleave from a finger the caption stayed up. */
|
||||||
|
mouseHoverOnly?: boolean;
|
||||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||||
@@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
|||||||
move: false,
|
move: false,
|
||||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||||
mouseOnly: !isHover || !!opts.pointerOnly,
|
mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly,
|
||||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||||
// menu, so the "did they mean it" question is mostly answered.
|
// menu, so the "did they mean it" question is mostly answered.
|
||||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function trapTab(
|
|||||||
// second copy of the lock is gone.
|
// second copy of the lock is gone.
|
||||||
let scrollLocks = 0;
|
let scrollLocks = 0;
|
||||||
let unlockedOverflow = "";
|
let unlockedOverflow = "";
|
||||||
function lockBodyScroll(): () => void {
|
export function lockBodyScroll(): () => void {
|
||||||
if (scrollLocks++ === 0) {
|
if (scrollLocks++ === 0) {
|
||||||
unlockedOverflow = document.body.style.overflow;
|
unlockedOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
Share2,
|
Share2,
|
||||||
|
SkipBack,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -76,7 +77,7 @@ export function useDownloadActions({
|
|||||||
onEditDetails,
|
onEditDetails,
|
||||||
onManageShares,
|
onManageShares,
|
||||||
}: {
|
}: {
|
||||||
onPlay: (job: DownloadJob) => void;
|
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
|
||||||
onEdit: (job: DownloadJob) => void;
|
onEdit: (job: DownloadJob) => void;
|
||||||
onEditDetails: (job: DownloadJob) => void;
|
onEditDetails: (job: DownloadJob) => void;
|
||||||
onManageShares: (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 icon = (I: typeof Play) => <I className="w-4 h-4" />;
|
||||||
const play = (job: DownloadJob): ActionMenuItem => ({
|
// A started item continues, and can also start over — the card menus' words and glyph (C5).
|
||||||
id: "play",
|
const play = (job: DownloadJob): ActionMenuItem[] => {
|
||||||
label: t("downloads.actions.play"),
|
const started = (job.position_seconds ?? 0) > 0;
|
||||||
icon: icon(PlayCircle),
|
return [
|
||||||
onSelect: () => onPlay(job),
|
{
|
||||||
});
|
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
|
// 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.
|
// attachment, so the page stays where it is.
|
||||||
const save = (job: DownloadJob): ActionMenuItem => ({
|
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
|
// 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.
|
// errand, ahead of saving the file.
|
||||||
[
|
[
|
||||||
...(a.play ? [play(job)] : []),
|
...(a.play ? play(job) : []),
|
||||||
{
|
{
|
||||||
id: "share",
|
id: "share",
|
||||||
label: t("downloads.actions.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.
|
// Same media_kind + can_download gate as the library, via the shared helper.
|
||||||
const a = sharedRowActions(job);
|
const a = sharedRowActions(job);
|
||||||
return menuGroups(
|
return menuGroups(
|
||||||
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
|
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
|
||||||
a.edit ? [edit(job)] : [],
|
a.edit ? [edit(job)] : [],
|
||||||
copies(job),
|
copies(job),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
import { type PlexCard } from "../lib/api";
|
import { type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
|
||||||
import { pinPlex, useComfortPin } from "./comfortPin";
|
import { pinPlex, useComfortPin } from "./comfortPin";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "./copyItems";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
@@ -46,15 +49,27 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
||||||
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
|
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
|
||||||
|
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
|
||||||
|
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
|
||||||
|
// own page).
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
|
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
|
||||||
// only a leaf is added from a card.
|
// only a leaf is added from a card.
|
||||||
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
|
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
|
||||||
|
|
||||||
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
|
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
|
||||||
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */
|
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
|
||||||
|
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
|
||||||
|
* without one the row is left out rather than opening the panel at a corner. Required, so every
|
||||||
|
* host menu decides: an optional one let a menu that has an anchor drop the row silently. */
|
||||||
const items = (
|
const items = (
|
||||||
c: PlexCard,
|
c: PlexCard,
|
||||||
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
|
{
|
||||||
|
play = true,
|
||||||
|
pin: withPin = true,
|
||||||
|
anchor,
|
||||||
|
}: { play?: boolean; pin?: boolean; anchor: Element | null },
|
||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
||||||
const playable = isPlexPlayable(c);
|
const playable = isPlexPlayable(c);
|
||||||
@@ -104,6 +119,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// A movie or a show can be a collection member; an episode cannot.
|
||||||
|
...(isAdmin && anchor && c.type !== "episode"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "collection",
|
||||||
|
label: t("plex.collectionAdd.row"),
|
||||||
|
icon: <Layers className={icon} />,
|
||||||
|
separatorBefore: !playable,
|
||||||
|
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(playable && onInfo
|
...(playable && onInfo
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -120,7 +147,14 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const panel = adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
|
const panel = (
|
||||||
|
<>
|
||||||
|
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
|
||||||
|
{collecting && (
|
||||||
|
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
return { items, add, panel };
|
return { items, add, panel };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,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",
|
"version": "0.101.0",
|
||||||
"date": "2026-10-06",
|
"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",
|
"version": "0.101.0",
|
||||||
"date": "2026-10-06",
|
"date": "2026-10-06",
|
||||||
|
|||||||
@@ -121,9 +121,9 @@
|
|||||||
"menu": "Media",
|
"menu": "Media",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
|
"markNextWatched": "Mark {{name}} watched",
|
||||||
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
|
"markNextUnwatched": "Mark {{name}} unwatched",
|
||||||
"nextUp": "Next: S{{season}}·E{{episode}}",
|
"nextUp": "Next: {{name}}",
|
||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "Season {{n}}",
|
"seasonTitle": "Season {{n}}",
|
||||||
"specials": "Specials",
|
"specials": "Specials",
|
||||||
@@ -384,5 +384,11 @@
|
|||||||
"byGenre": "Browse by genre",
|
"byGenre": "Browse by genre",
|
||||||
"becauseWatched": "Because you watched {{title}}",
|
"becauseWatched": "Because you watched {{title}}",
|
||||||
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
|
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
|
||||||
|
},
|
||||||
|
"collectionAdd": {
|
||||||
|
"row": "Add to collection…",
|
||||||
|
"title": "Add to collection",
|
||||||
|
"none": "No collection to add to yet — name one below.",
|
||||||
|
"loadFailed": "Couldn't load the collections"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,9 @@
|
|||||||
"menu": "Média",
|
"menu": "Média",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
|
"markNextWatched": "{{name}} megnézettnek jelöl",
|
||||||
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
|
"markNextUnwatched": "{{name}} nem-nézettnek jelöl",
|
||||||
"nextUp": "Következik: S{{season}}·E{{episode}}",
|
"nextUp": "Következik: {{name}}",
|
||||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||||
"seasonTitle": "{{n}}. évad",
|
"seasonTitle": "{{n}}. évad",
|
||||||
"specials": "Különkiadások",
|
"specials": "Különkiadások",
|
||||||
@@ -384,5 +384,11 @@
|
|||||||
"byGenre": "Böngészés műfaj szerint",
|
"byGenre": "Böngészés műfaj szerint",
|
||||||
"becauseWatched": "Mert megnézted: {{title}}",
|
"becauseWatched": "Mert megnézted: {{title}}",
|
||||||
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
|
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
|
||||||
|
},
|
||||||
|
"collectionAdd": {
|
||||||
|
"row": "Kollekcióhoz adás…",
|
||||||
|
"title": "Kollekcióhoz adás",
|
||||||
|
"none": "Még nincs kollekció, amihez adhatnád – adj meg egy nevet lent.",
|
||||||
|
"loadFailed": "Nem sikerült betölteni a kollekciókat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,3 +454,31 @@ describe("uploadDownload's wire shape", () => {
|
|||||||
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -285,11 +285,13 @@ export function identityHeaders(): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
|
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
|
||||||
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
|
// still make: the resume checkpoints of the YouTube, Plex and library players, the Plex session stop,
|
||||||
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
|
// the diagnostics flush. A normal fetch is cancelled on unload and React effect cleanup doesn't run
|
||||||
// would be lost.
|
// 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 {
|
// `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 {
|
try {
|
||||||
void send(url, {
|
void send(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -297,7 +299,10 @@ export function beacon(url: string, body: Record<string, unknown>): void {
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).catch(() => {});
|
}).then(
|
||||||
|
() => onSettled?.(),
|
||||||
|
() => onSettled?.(),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
// 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
|
// 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.
|
// 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 {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
@@ -97,6 +97,9 @@ export interface DownloadJob {
|
|||||||
asset: DownloadAsset | null;
|
asset: DownloadAsset | null;
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
user_email?: string;
|
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 {
|
export interface ShareRecipient {
|
||||||
@@ -274,6 +277,38 @@ export const downloadsApi = {
|
|||||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
||||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
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
|
// 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) —
|
// 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
|
// otherwise the download resolves to the session-default account and 404s for a per-tab
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export interface PlexMarker {
|
|||||||
type: "intro" | "credits";
|
type: "intro" | "credits";
|
||||||
start_s: number;
|
start_s: number;
|
||||||
end_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
|
// 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.
|
// (`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 {
|
export interface PlexPlaySession {
|
||||||
mode: "direct" | "hls";
|
mode: "direct" | "hls";
|
||||||
url: string;
|
url: string;
|
||||||
|
id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession)
|
||||||
start_s: number;
|
start_s: number;
|
||||||
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
|
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)
|
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();
|
const qs = u.toString();
|
||||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
// A movie or show card's "Add to collection": its library's manual collections, each saying whether
|
||||||
|
// the title is in it already.
|
||||||
|
plexCollectionsFor: (
|
||||||
|
itemRk: string,
|
||||||
|
): Promise<{ collections: (PlexCollection & { contains: boolean })[] }> =>
|
||||||
|
req(`/api/plex/collections/for/${encodeURIComponent(itemRk)}`),
|
||||||
// One collection's hero + its members in the requested order (the Collection info page). The sort
|
// One collection's hero + its members in the requested order (the Collection info page). The sort
|
||||||
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
|
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
|
||||||
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
|
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
|
||||||
@@ -717,6 +726,14 @@ export const plexApi = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ position_seconds, duration_seconds, final }),
|
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
|
// 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
|
// 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
|
// (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 });
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
// The browser's Fullscreen API, WebKit included.
|
// The browser's Fullscreen API, WebKit included.
|
||||||
//
|
//
|
||||||
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
|
// Safari and iPadOS before 16.4 ship only the prefixed spelling: no `fullscreenchange`, no
|
||||||
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
|
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
|
||||||
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
|
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
|
||||||
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
|
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
|
||||||
@@ -29,11 +29,26 @@ export function fullscreenElement(): Element | null {
|
|||||||
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Can this browser put a plain element in fullscreen at all? Read off `Element.prototype`, never
|
||||||
|
* `document.fullscreenEnabled`: Brave on iPadOS switches element fullscreen off in its web view, then
|
||||||
|
* patches `HTMLElement.prototype.requestFullscreen` with a stand-in that only fullscreens a <video>
|
||||||
|
* and forces `fullscreenEnabled` to true — both claim a support that is not there, and the players'
|
||||||
|
* fullscreen buttons did nothing. A button that cannot work is not shown (owner). */
|
||||||
|
export function canFullscreenElements(): boolean {
|
||||||
|
if (typeof Element === "undefined") return false;
|
||||||
|
const proto = Element.prototype as Partial<WebkitElement>;
|
||||||
|
return (
|
||||||
|
typeof proto.requestFullscreen === "function" ||
|
||||||
|
typeof proto.webkitRequestFullscreen === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
|
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
|
||||||
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
|
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
|
||||||
export function requestFullscreen(el: HTMLElement): void {
|
export function requestFullscreen(el: HTMLElement): void {
|
||||||
const e = el as WebkitElement;
|
const e = el as WebkitElement;
|
||||||
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
|
// Wrapped: a patched stand-in (Brave's, above) may return a boolean, not a promise.
|
||||||
|
if (e.requestFullscreen) void Promise.resolve(e.requestFullscreen()).catch(() => {});
|
||||||
else e.webkitRequestFullscreen?.();
|
else e.webkitRequestFullscreen?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +110,10 @@ export function useFullscreenPortalHost(): HTMLElement | null {
|
|||||||
}, [host]);
|
}, [host]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!host) return;
|
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;
|
if (host.parentNode === parent) return;
|
||||||
const focused = host.contains(document.activeElement)
|
const focused = host.contains(document.activeElement)
|
||||||
? (document.activeElement as HTMLElement)
|
? (document.activeElement as HTMLElement)
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
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", () => {
|
describe("useBackToExit back-ladder", () => {
|
||||||
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
|
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
|
||||||
const outer = vi.fn();
|
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
|
// An overlay's Back callback is a Back STEP, and the contract lets one entry hold several. When the
|
||||||
// one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`,
|
// Plex player kept its panels on one entry, a single call left the entry up and the navigation was
|
||||||
// Shift+W in the player closed the info panel and nothing else.
|
// 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 () => {
|
it("asks again, a render apart, when one call peels only a step off the overlay", async () => {
|
||||||
const back = stubBack();
|
const back = stubBack();
|
||||||
try {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
|
// 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.
|
// 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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
import { useHistorySubview, type SubviewUrl } from "./history";
|
import { useBackToClose, useHistorySubview, type SubviewUrl } from "./history";
|
||||||
|
|
||||||
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors
|
// 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).
|
// 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("/"));
|
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", () => {
|
describe("useHistorySubview seed", () => {
|
||||||
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
|
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" });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||||
import { urlWithParams } from "./urlState";
|
import { urlWithParams } from "./urlState";
|
||||||
|
import { useLatest } from "./useLatest";
|
||||||
|
|
||||||
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
||||||
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
|
// 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;
|
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) ---------------------------------------
|
// --- 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
|
/** 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. */
|
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||||
export interface SubviewUrl<T> {
|
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
|
// 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 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.
|
// 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 }) => {
|
(next: T, opts?: { replace?: boolean }) => {
|
||||||
setView(next);
|
setView(next);
|
||||||
const kept = url?.entry ? url.entry(next) : next;
|
const kept = url?.entry ? url.entry(next) : next;
|
||||||
@@ -175,7 +203,21 @@ export function useHistorySubview<T>(
|
|||||||
},
|
},
|
||||||
[url],
|
[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"
|
// 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.
|
// can now write `[sub]` and mean it, instead of re-running on every parent render.
|
||||||
return useMemo(() => ({ view, open, back }), [view, open, back]);
|
return useMemo(() => ({ view, open, back }), [view, open, back]);
|
||||||
@@ -228,8 +270,14 @@ function reconcileHistory(): void {
|
|||||||
}
|
}
|
||||||
while (historyDepth > want) {
|
while (historyDepth > want) {
|
||||||
historyDepth--;
|
historyDepth--;
|
||||||
pendingProgrammatic++;
|
if (backStaysInDocument()) {
|
||||||
window.history.back();
|
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());
|
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,
|
// 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.
|
// but a future "discard your changes?" guard must not be navigated out from under.
|
||||||
const CLOSE_TIMEOUT_MS = 1500;
|
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
|
// An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
|
||||||
// panels share a single entry and Back peels one per press. Navigating over it asks again, a render
|
// call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
|
||||||
// apart (the callback reads what the last one closed), until the entry is gone or the timeout drops
|
// navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
|
||||||
// the command.
|
// reads what the last one closed), until the entry is gone or the timeout drops the command.
|
||||||
const RECLOSE_MS = 50;
|
const RECLOSE_MS = 50;
|
||||||
|
|
||||||
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
|
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export type KeyScope =
|
|||||||
| "contextMenu"
|
| "contextMenu"
|
||||||
| "ytPlayer"
|
| "ytPlayer"
|
||||||
| "plexPlayer"
|
| "plexPlayer"
|
||||||
|
| "mediaPlayer"
|
||||||
| "plexBrowse";
|
| "plexBrowse";
|
||||||
|
|
||||||
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
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);
|
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||||
|
|
||||||
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
||||||
type ShortcutWhen = "episode";
|
type ShortcutWhen = "episode" | "video";
|
||||||
|
|
||||||
export interface Shortcut {
|
export interface Shortcut {
|
||||||
/** Unique within its scope; the handler key `dispatch()` demands. */
|
/** Unique within its scope; the handler key `dispatch()` demands. */
|
||||||
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
|
|||||||
allowInInput: true,
|
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) ----------------------------------------------------------
|
// --- the Plex browser (drill-down) ----------------------------------------------------------
|
||||||
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
|
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
|
||||||
] as const satisfies readonly Shortcut[];
|
] as const satisfies readonly Shortcut[];
|
||||||
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
|
|||||||
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
||||||
const helpConditions = createStore<HelpConditions>({});
|
const helpConditions = createStore<HelpConditions>({});
|
||||||
|
|
||||||
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
|
/** 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. */
|
* 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 {
|
export function useHelpConditions(conditions: HelpConditions): void {
|
||||||
const episode = conditions.episode;
|
const { episode, video } = conditions;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
helpConditions.set({ episode });
|
helpConditions.set({ episode, video });
|
||||||
return () => helpConditions.set({});
|
return () => helpConditions.set({});
|
||||||
}, [episode]);
|
}, [episode, video]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -205,9 +205,31 @@ export function useCrossfadeBackdrop(
|
|||||||
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
||||||
export function epLabel(ep?: PlexCard | null): string | undefined {
|
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||||
if (!ep) return undefined;
|
if (!ep) return undefined;
|
||||||
if (ep.season_number != null && ep.episode_number != null)
|
const n = episodeNumbers(ep);
|
||||||
return `S${ep.season_number} · E${ep.episode_number}`;
|
return n ? `S${n.season} · E${n.episode}` : ep.title;
|
||||||
return 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
|
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user