release: 0.102.0 (improvement/e37-player)

This commit is contained in:
2026-10-08 01:58:01 +02:00
78 changed files with 4287 additions and 856 deletions
+10
View File
@@ -7,6 +7,16 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.102.0 — 2026-10-08
### New
- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped.
### Fixed
- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream.
## 0.101.0 — 2026-10-06
### New
+1
View File
@@ -102,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
+3 -2
View File
@@ -23,8 +23,9 @@ blocker and SponsorBlock keep working.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
- **In-app player** with resume, plus keyboard/scroll controls.
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
to your device, share with another user, or hand out a public watch link.
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
where you left off, then save to your device, share with another user, or hand out a public watch
link.
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
movies and shows alongside your feed, with watch state kept in sync.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
+1 -1
View File
@@ -1 +1 @@
0.101.0
0.102.0
-8
View File
@@ -743,14 +743,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 37,
"endColumn": 47,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -0,0 +1,42 @@
"""library: a resume point per user and library item
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
status: the library has no "watched". It starts empty; the downgrade drops it.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0099_library_states"
down_revision: str | None = "0098_drop_demo_account"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"library_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"job_id",
sa.Integer(),
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position_seconds", sa.Integer(), nullable=False),
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
)
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
def downgrade() -> None:
op.drop_index("ix_library_states_job_id", table_name="library_states")
op.drop_table("library_states")
@@ -0,0 +1,22 @@
"""plex_items: drop `markers`, a column nothing writes or reads
Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag
on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade
restores the empty column.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0100_drop_plex_item_markers"
down_revision: str | None = "0099_library_states"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.drop_column("plex_items", "markers")
def downgrade() -> None:
op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True))
+21 -2
View File
@@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LibraryState(Base):
"""Where a user stopped in a library item (their own download, or one shared with them) — the
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
__tablename__ = "library_states"
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
job_id: Mapped[int] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search.
@@ -1253,8 +1274,6 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
+27 -8
View File
@@ -744,8 +744,9 @@ def start_session(
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
# registered session is no longer `replace_only`, we abort and return None, and the caller
# answers with its own dead session — never the newer one, whose id a stale requester would
# stop. The INITIAL spawn never gets None — see its call site.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
@@ -840,12 +841,13 @@ def start_session(
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests.
return current_session(user_id, key) or s
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
# Never None either: the POST /session route turns a None return into a "Media file not found
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
return s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready:
@@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
return dropped
def stop_session(user_id: int, key: str, session_id: str) -> bool:
"""Stop this viewer's session for `key` now, because the player that asked for it is gone (left
the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the
film first — measured: ~4 cores for a minute after leaving a resumed 480p film.
Only while `session_id` is still the registered one: the player names the session it played, and
a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's
current playback, not this request's to end."""
with _lock:
s = _sessions.get((user_id, key))
if s is None or s.id != session_id:
return False
_kill(s)
_sessions.pop((user_id, key), None)
return True
def touch_session(user_id: int, key: str) -> None:
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
+37 -1
View File
@@ -9,16 +9,52 @@ one existence-leak policy.
from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
from app.models import POSITION_MAX_S, User
from app.issues import gitea
from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base)
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
# library's): positions below this are "didn't really start" and within this of the end are
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by
# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin
# (30s) because it marks long-form items watched, not just clears a resume bar — see
# routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
class ProgressIn(BaseModel):
"""A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the
page goes away)."""
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (`kept_position`) — the player may post a raw currentTime (a float),
# which the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on
# the progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
def kept_position(body: ProgressIn) -> int:
"""The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to
count as started, or close enough to the end to count as finished)."""
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0
def not_found(what: str = "generic") -> HTTPException:
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
+68 -4
View File
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
import os
import re
import tempfile
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -17,7 +17,8 @@ from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
@@ -39,11 +40,19 @@ from app.models import (
DownloadProfile,
DownloadQuota,
DownloadShare,
LibraryState,
MediaAsset,
User,
display_text,
)
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
from app.routes._common import (
ProgressIn,
capped_stream_to,
get_or_404,
kept_position,
not_found,
owned_or_404,
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
@@ -165,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows}
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
ids = [j.id for j in jobs]
if not ids:
return {}
rows = db.execute(
select(LibraryState.job_id, LibraryState.position_seconds).where(
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
)
).all()
return {job_id: pos for job_id, pos in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
@@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
positions = _positions_for(db, user, jobs)
return [
{
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0),
}
for j in jobs
]
@router.get("/usage")
@@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
positions = _positions_for(db, user, jobs)
out = []
for j in jobs:
data = _serialize(j, assets.get(j.asset_id))
data["shared"] = True
data["position_seconds"] = positions.get(j.id, 0)
out.append(data)
return out
@@ -825,6 +856,39 @@ def download_file(
# --- editor filmstrip (scrub track) --------------------------------------------------------
@router.post("/{job_id}/progress")
def set_library_progress(
job_id: int,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
one shared with them). A position too early or too close to the end clears it instead."""
_accessible_job(db, user, job_id)
position = kept_position(body)
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
if not position:
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
# one stop may both clear it, and the second then deletes nothing rather than failing.
db.execute(delete(LibraryState).where(mine))
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": 0}
now = datetime.now(timezone.utc)
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
# read-then-insert that the second would fail on the unique key.
db.execute(
pg_insert(LibraryState)
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
.on_conflict_do_update(
constraint="uq_library_user_job",
set_={"position_seconds": position, "progress_updated_at": now},
)
)
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": position}
@router.get("/{job_id}/storyboard")
def storyboard_meta(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+11 -34
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -26,14 +26,19 @@ from app.feedquery import (
windowed_relevance_rows,
)
from app.models import (
POSITION_MAX_S,
ChannelTag,
Tag,
User,
Video,
VideoState,
)
from app.routes._common import get_or_404, youtube_refusal, not_found
from app.routes._common import (
ProgressIn,
get_or_404,
kept_position,
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"}
# Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth
# storing, so we clear the position instead (the video shows no progress bar).
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
@@ -326,23 +323,10 @@ def clear_video_state(
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
class VideoProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
@router.post("/videos/{video_id}/progress")
def set_video_progress(
video_id: str,
body: VideoProgressIn,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -350,22 +334,15 @@ def set_video_progress(
Stores a per-user position on the video_states row without touching watch status, so a
partially-watched video can render a progress bar and match the "in progress" filter.
Trivially-early and near-finished positions clear the position rather than store it."""
position = int(body.position_seconds)
duration = int(body.duration_seconds)
if position < 0:
position = 0
position = kept_position(body)
get_or_404(db, Video, video_id, what="video")
# Decide whether this position is worth keeping (else clear it).
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
keep = position >= PROGRESS_MIN_SECONDS and not near_end
row = db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
).scalar_one_or_none()
if not keep:
if not position:
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
# zero the position (a saved/hidden video keeps its status).
if row is not None:
+4
View File
@@ -404,6 +404,10 @@ def item_detail(
"type": m.get("type"),
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
# An item can carry several credits blocks with scenes between them; Plex
# flags the one that really ends it. Which credits may leave the item is the
# player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it.
"final": bool(m.get("final")),
}
)
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
+24 -17
View File
@@ -15,7 +15,7 @@ from fastapi import (
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -23,7 +23,6 @@ from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db
from app.models import (
POSITION_MAX_S,
PlexItem,
PlexSeason,
PlexShow,
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import not_found
from app.routes._common import ProgressIn, not_found
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -160,11 +159,11 @@ def subtitle(
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
# Resume/watched thresholds. _PROGRESS_MIN_S matches the feed's PROGRESS_MIN_SECONDS (5), but
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
# not drift. Keep them separate on purpose (see routes/feed.py).
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
# Keep them separate on purpose (see routes/_common.py).
_PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
return False
class ItemProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
class ItemProgressIn(ProgressIn):
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
final: bool = False
@@ -637,12 +629,27 @@ def stream_session(
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
# names resolve back into the same session — see `stream_hls_file` for why it has to.
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
# What the player names when it no longer wants the session (POST …/session/{id}/stop).
"id": s.id,
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
}
@router.post("/stream/{rating_key}/session/{session_id}/stop")
def stream_session_stop(
rating_key: str,
session_id: str,
user: User = Depends(current_user),
) -> dict:
"""The player no longer wants this session (it left the item, closed, or the page unloaded):
its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a
keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and
only while it is still the one registered for the item (see `stop_session`)."""
return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)}
@router.get("/stream/{rating_key}/file")
def stream_file(
rating_key: str,
+135
View File
@@ -0,0 +1,135 @@
"""The library player's resume point (`library_states`).
A checkpoint stores where the caller stopped in a library item they can play — their own download,
or one shared with them — and the two library listings hand it back per item, so the player opens
there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller
cannot reach, is ever read or written. DB-backed (real Postgres).
"""
import pytest
from sqlalchemy import select
from app.models import DownloadJob, DownloadShare, LibraryState
from app.routes._common import ProgressIn, kept_position
def _done_job(db, user) -> DownloadJob:
job = DownloadJob(
user_id=user.id,
source_kind="url",
source_ref="https://example.com/a.mp4",
status="done",
profile_snapshot={},
)
db.add(job)
db.commit()
return job
def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int:
"""Send a checkpoint the caller may make; the point the server kept (0 = cleared)."""
r = client.post(
f"/api/downloads/{job_id}/progress",
json={"position_seconds": position, "duration_seconds": duration},
)
assert r.status_code == 200, r.text
return r.json()["position_seconds"]
def _states(db) -> list[tuple[int, int, int]]:
db.expire_all()
rows = db.execute(
select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds)
).all()
return [tuple(r) for r in rows]
def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
r = c.post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 754.9, "duration_seconds": 3000},
)
assert r.status_code == 200 and r.json() == {
"ok": True,
"job_id": job.id,
"position_seconds": 754,
}
[item] = c.get("/api/downloads").json()
assert item["position_seconds"] == 754
# A second checkpoint moves it rather than adding a row.
assert _checkpoint(c, job.id, 900) == 900
assert _states(db) == [(me.id, job.id, 900)]
def test_an_item_never_played_lists_zero(db, make_user, as_user):
me = make_user()
_done_job(db, me)
[item] = as_user(me).get("/api/downloads").json()
assert item["position_seconds"] == 0
def test_too_early_or_near_the_end_clears_it(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 2995) == 0
assert _states(db) == []
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 3) == 0
assert _states(db) == []
# Clearing what is not there is not an error (the beacon and the last checkpoint of one stop).
assert _checkpoint(c, job.id, 0) == 0
# The edges themselves, one second either side — the same rows as the client's
# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared
# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side.
@pytest.mark.parametrize(
("position", "duration", "kept"),
[(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)],
)
def test_the_kept_point_at_its_exact_edges(position, duration, kept):
body = ProgressIn(position_seconds=position, duration_seconds=duration)
assert kept_position(body) == kept
def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user):
owner, friend = make_user(), make_user()
job = _done_job(db, owner)
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
db.commit()
assert _checkpoint(as_user(owner), job.id, 100) == 100
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
owner, stranger = make_user(), make_user()
job = _done_job(db, owner)
r = as_user(stranger).post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 600, "duration_seconds": 3000},
)
assert r.status_code == 404
assert _states(db) == []
def test_deleting_the_job_deletes_its_places(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
assert _checkpoint(as_user(me), job.id, 600) == 600
db.delete(db.get(DownloadJob, job.id))
db.commit()
assert _states(db) == []
+35
View File
@@ -401,3 +401,38 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title(
("magyar Forced", "hu", True, False, None),
("English SDH", "en", False, True, None),
]
def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep):
"""An item can carry several credits markers — mid- and post-credit scenes sit between them —
and only the one Plex flags `final` is the real end; the player needs that flag to know which
credits may move on to the next item."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man"))
db.commit()
user = _user(db, "markers@example.com")
db.commit()
meta = {
"Marker": [
{"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000},
{"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000},
{
"type": "credits",
"startTimeOffset": 7549000,
"endTimeOffset": 7559000,
"final": True,
},
{"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2},
]
}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2")
assert resp.status_code == 200, resp.text
assert resp.json()["markers"] == [
{"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False},
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
]
@@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
assert body["mode"] == "hls"
assert body["transcode"] is False
assert captured["transcode"] is False
# The id the player names when it stops the session (POST …/session/{id}/stop).
assert body["id"] == "sid"
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
+101
View File
@@ -0,0 +1,101 @@
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
played; the server stops it only while it is still that viewer's registered session for the item, so
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
"""
import time
import pytest
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.main import app
from app.models import User
from app.plex import stream as plex_stream
VIEWER = 41
class _Proc:
def __init__(self):
self.terminated = False
def poll(self):
return None
def terminate(self):
self.terminated = True
def wait(self, timeout=None):
return 0
class _Session:
def __init__(self, session_id: str, directory):
self.id = session_id
self.dir = directory
self.proc = _Proc()
self.last_access = time.time()
@pytest.fixture
def sessions(monkeypatch):
live: dict = {}
monkeypatch.setattr(plex_stream, "_sessions", live)
return live
@pytest.fixture
def client(monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True)
user = User(id=VIEWER, email="stop@example.com")
override_dep(current_user, lambda: user)
return TestClient(app)
def _session(tmp_path, sid: str) -> _Session:
d = tmp_path / sid
d.mkdir()
return _Session(sid, d)
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
s = _session(tmp_path, "sid-a")
sessions[(VIEWER, "m1")] = s
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": True}
assert s.proc.terminated
assert sessions == {}
assert not s.dir.exists()
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
newer = _session(tmp_path, "sid-b")
sessions[(VIEWER, "m1")] = newer
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not newer.proc.terminated
assert sessions == {(VIEWER, "m1"): newer}
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
theirs = _session(tmp_path, "sid-a")
sessions[(7, "m1")] = theirs
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not theirs.proc.terminated
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": False}
+7 -2
View File
@@ -695,7 +695,9 @@ class TestTranscodeStartSession:
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
# The identity guard must detect it's been superseded and yield the currently-registered session.
# The identity guard must detect it's been superseded. It answers with its OWN (dead) session,
# never the newer one: the answer goes to the request that lost, whose player drops it as stale
# and stops the session it names — naming the newer one stopped the playback that won.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
newer = _Newer()
captured = {}
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
captured["s"] = stream._sessions[(7, "12345")]
stream._sessions[(7, "12345")] = newer
return False
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
)
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert s is captured["s"] # its own session, not the newer one
assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
+8 -2
View File
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
return TestClient(app)
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
def test_a_non_finite_position_is_refused(client, path, raw):
r = client.post(
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
assert r.status_code == 422, r.text
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
+4 -4
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn
from app.routes.feed import VideoProgressIn
from app.routes._common import ProgressIn
from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path.
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError):
VideoProgressIn(position_seconds="not-a-number")
ProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
+7 -2
View File
@@ -528,7 +528,9 @@ of a full-width panel and nothing on the right.
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
"Play from the beginning", and its picture carries the cards' progress bar.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
@@ -541,7 +543,9 @@ of a full-width panel and nothing on the right.
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG]
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
focused ✕ closed the film. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
@@ -706,3 +710,4 @@ of a full-width panel and nothing on the right.
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
]);
});
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
+16 -4
View File
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
// What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
{playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
@@ -5,7 +5,9 @@
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render as rtlRender, cleanup } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const baseJob: DownloadJob = {
id: 7,
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
},
});
afterEach(cleanup);
// The player writes its resume point into the library listings' cache, so it sits under a client.
const render = (ui: ReactElement, client = new QueryClient()) =>
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
function loaded(duration = 600) {
const video = document.body.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
@@ -130,4 +153,81 @@ describe("LibraryPlayer", () => {
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.parentElement;
});
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
const panel = document.activeElement as HTMLElement;
expect(panel.tagName).toBe("DIV");
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
});
it("opens where the viewer stopped — or from the start when asked", () => {
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
render(<LibraryPlayer job={job} onClose={() => {}} />);
expect(loaded().currentTime).toBe(300);
cleanup();
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
position_seconds: 125,
});
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 125.4;
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
});
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
// The listing polls. One already out when the player closed answered with the point the server
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
// first checkpoint wrote that older point back over the right one.
let settled: (() => void) | undefined;
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
settled = onSettled;
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 300;
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
});
});
+58 -8
View File
@@ -1,10 +1,15 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { keptPosition, useResume } from "../lib/resume";
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
// any codec (best-effort playback).
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
//
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
// controls and the basic keys drive it like the other players.
export default function LibraryPlayer({
job,
fromStart = false,
onClose,
}: {
job: DownloadJob;
fromStart?: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
@@ -32,16 +50,48 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const mediaRef = useCallback(
(el: HTMLMediaElement | null) => {
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
logMedia(el);
setMedia(el);
},
[logMedia],
);
const title = jobTitle(job);
useMediaSession(media, {
title,
artist: job.display_uploader ?? asset?.uploader ?? null,
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch. On the way out a listing poll may already be out, having read the
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
// one. Once the point is on the server, the listings are read again.
const lists = [qk.downloads(), qk.downloadsShared()];
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of lists) {
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
}
if (leaving) {
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
});
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
return (
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
@@ -51,13 +101,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={videoRef}
ref={mediaRef}
src={fileUrl}
poster={poster}
controls
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// The provider reads the signed-in account (the tab title) from the `me` cache.
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -206,6 +220,31 @@ describe("NavigationProvider · Back", () => {
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
+15 -7
View File
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
}
}, []);
@@ -143,9 +146,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
window.history.replaceState(st, "");
}
}, []);
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
+2 -1
View File
@@ -10,6 +10,7 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
DETAIL_POSTER,
episodeLine,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
@@ -315,7 +316,7 @@ export default function PlexInfo({
)}
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
{episodeLine(detail)}
</p>
)}
</div>
@@ -358,7 +358,7 @@ describe("keyboard focus in the bands", () => {
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
const intro = {
...item("A"),
markers: [{ type: "intro", start_s: 50, end_s: 200 }],
markers: [{ type: "intro", start_s: 50, end_s: 200, final: false }],
} as unknown as PlexItemDetail;
mount({ details: { A: intro } });
await playingAt("/media/A", 100);
@@ -0,0 +1,183 @@
// @vitest-environment jsdom
//
// What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks
// menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces
// the other, so two can never draw on top of each other. The stats readout is not one of them — it
// stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
// leave the player; the mouse/browser Back peels them one per press too, the one opened last first.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
for (const [name, value] of Object.entries({
play: () => Promise.resolve(),
pause: () => {},
load: () => {},
}))
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const detail = {
id: "1",
title: "Title 1",
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: 0,
audio_streams: [],
subtitle_streams: [],
markers: [],
cast: [],
genres: [],
} as unknown as PlexItemDetail;
async function mount() {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexSession").mockResolvedValue({
mode: "direct",
url: "/media/1",
start_s: 0,
} as Awaited<ReturnType<typeof api.plexSession>>);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const onClose = vi.fn();
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={onClose} />
</QueryClientProvider>,
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
);
return onClose;
}
/** The browser's Back, waited out to its popstate. */
const browserBack = () =>
act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
const press = (key: string) =>
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
});
// `hidden`: the faded-out control bands are aria-hidden while the video plays.
const settings = () => screen.queryByRole("dialog", { name: "plex.player.settings", hidden: true });
const stats = () => screen.queryByRole("region", { name: "plex.player.stats.title", hidden: true });
const moreMenu = () => screen.queryByRole("menu", { hidden: true });
// The info overlay is the full-screen scrim the media info sits in.
const info = () => document.querySelector(".place-items-center.z-menu");
async function openSettings() {
fireEvent.click(screen.getByLabelText("plex.player.more"));
const row = await waitFor(() => {
const r = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.player.settings");
expect(r).toBeDefined();
return r!;
});
fireEvent.click(row);
await waitFor(() => expect(settings()).not.toBeNull());
}
describe("PlexPlayer · one panel at a time", () => {
it("the browser Back with the ⋯ menu open closes the menu, not the player", async () => {
const onClose = await mount();
fireEvent.click(screen.getByLabelText("plex.player.more"));
await waitFor(() => expect(moreMenu()).not.toBeNull());
// The menu claims a history entry, so the Back below is spent on it. First in the file: the
// history module's entry count is per module, and an earlier test's entries would be in it.
await waitFor(() => expect(window.history.state?._ov).toBeDefined());
await browserBack();
await waitFor(() => expect(moreMenu()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
it("the browser Back peels each layer that is up, one press at a time", async () => {
// One shared history entry for every layer: the first Back spent it on one layer, and the
// second left the player with the stats readout still up. Second in the file (see above): the
// first test leaves no entry behind.
window.history.pushState({ page: "player" }, ""); // the entry the player was opened on
const onClose = await mount();
press("d");
press("i");
await waitFor(() => expect(info()).not.toBeNull());
await browserBack();
await waitFor(() => expect(info()).toBeNull());
expect(stats()).not.toBeNull();
await browserBack();
await waitFor(() => expect(stats()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
it("the info overlay replaces the tuning panel rather than drawing over it", async () => {
await mount();
await openSettings();
press("i");
await waitFor(() => expect(info()).not.toBeNull());
expect(settings()).toBeNull();
});
it("the stats readout stays up beside a panel; Escape peels the panel, then the stats, then leaves", async () => {
const onClose = await mount();
press("d");
press("i");
await waitFor(() => expect(info()).not.toBeNull());
expect(stats()).not.toBeNull();
press("Escape");
expect(info()).toBeNull();
expect(stats()).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
press("Escape");
expect(stats()).toBeNull();
expect(onClose).not.toHaveBeenCalled();
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -308,6 +308,45 @@ describe("leaving an item that was played through", () => {
act(() => void el.dispatchEvent(new Event("ended")));
};
// A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history
// entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an
// open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog.
const overlayPushes = (push: { mock: { calls: unknown[][] } }) =>
push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null);
it("gives the auto-skip countdown no history entry", async () => {
prefs({ autoSkipIntro: true, autoSkipDelay: 5 });
const withIntro = {
...item("A"),
markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }],
} as unknown as PlexItemDetail;
mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } });
await playingAt("/media/A", 50);
await tick();
const push = vi.spyOn(window.history, "pushState");
await playingAt("/media/A", 110); // inside the intro: the countdown starts
await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy());
await tick();
expect(overlayPushes(push)).toEqual([]);
});
it("gives the up-next card no history entry", async () => {
prefs({ upNext: true });
mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
await tick();
const push = vi.spyOn(window.history, "pushState");
endMedia();
await screen.findByText("Title B"); // the card names the next item
await tick();
expect(overlayPushes(push)).toEqual([]);
});
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false });
const { qc, progress } = mount({
@@ -334,7 +373,7 @@ describe("leaving an item that was played through", () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
@@ -446,7 +485,7 @@ describe("leaving an item that was played through", () => {
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
markers: [{ type: "credits", start_s: 2000, end_s: 2990, final: true }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
@@ -463,6 +502,56 @@ describe("leaving an item that was played through", () => {
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
});
it("lets a credits block that does not end the item play on, its Skip only jumping past it", async () => {
// An item can carry several credits blocks with scenes between them: the first one moving on
// to the next item skipped a post-credit scene.
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const twoCredits = {
...item("A"),
markers: [
{ type: "credits", start_s: 2000, end_s: 2100, final: false },
{ type: "credits", start_s: 2900, end_s: 2995, final: true },
],
} as unknown as PlexItemDetail;
const { qc } = mount({
details: { A: twoCredits, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2010);
await tick();
expect(screen.queryByText("Title B")).toBeNull();
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("B", "en"))).toBeUndefined();
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
await waitFor(() => expect(startsAsked("A").slice(-1)).toEqual([2100])); // past the block, still A
expect(screen.queryByText("Title B")).toBeNull();
expect(setState).not.toHaveBeenCalled();
await playingAt("/media/A", 2910); // the final block: the auto-skip moves on
await screen.findByText("Title B");
expect(setState).toHaveBeenCalledWith("A", "watched");
});
it("does not leave on its own at credits Plex did not flag final, but its Skip does", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const unflagged = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: false }],
} as unknown as PlexItemDetail;
mount({
details: { A: unflagged, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2910);
await tick();
expect(screen.queryByText("Title B")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
await screen.findByText("Title B");
expect(setState).toHaveBeenCalledWith("A", "watched");
});
it("records a second viewing when the viewer plays on from the finished frame", async () => {
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
@@ -502,11 +591,18 @@ describe("restarting the session before playback has ticked", () => {
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
const answers = [deferred(), deferred(), deferred()];
let calls = 0;
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
// One request per item in flight: the switch waits for the start's answer…
await act(async () => answers[0]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
fireEvent.keyDown(window, { key: "ArrowRight" });
await act(async () => answers[1]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
@@ -529,12 +625,41 @@ it("a seek made while a restart is pending wins over it", async () => {
await playingAt("/media/A", 95);
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
await tick();
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
// on localdev, 2026-10-06).
let calls = 0;
const restart = deferred();
mount({
details: { A: item("A", 1200) },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? restart.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
await playingAt("/media/A", 1200);
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
await tick();
expect(startsAsked("A")).toEqual([1200, 1190]);
await act(async () => restart.resolve(direct("A")));
// The queued target is the LAST press's, asked once.
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },
@@ -645,3 +770,38 @@ it("a session answer that lands while the next item's detail is still loading is
await tick();
expect(video().getAttribute("src")).not.toBe("/media/B");
});
describe("the server session the player no longer wants", () => {
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
ReturnType<typeof api.plexSession>
>;
it("is stopped by its item and its own id when the page is unloaded", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
act(
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
);
expect(stop).toHaveBeenCalledWith("A", "sid-A");
});
it("is stopped the same way when the player closes", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
const { unmount } = mount({
details: { A: item("A") },
sessions: { A: () => Promise.resolve(hlsA) },
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
unmount();
expect(stop).toHaveBeenCalledWith("A", "sid-A");
});
it("is kept when the page only goes into the back/forward cache", async () => {
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
expect(stop).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+11 -44
View File
@@ -1,8 +1,8 @@
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
import type { HlsEngine } from "../lib/hlsEngine";
import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats";
import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
@@ -23,29 +23,24 @@ function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: numb
export function PlexStatsOverlay({
videoRef,
hlsRef,
engine,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
// long as this panel) — the source of the MEASURED bitrate row.
fragMeterRef: RefObject<FragMeter>;
audioLabel?: string; // selected audio track label, as the tracks menu names it
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const hls = hlsRef.current;
const { dropped, total } = frameCounts(v);
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
}
}
let level: HlsLevelInfo | null = null;
if (hls) {
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
level = {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
}
const level = engine.levelInfo();
setRows(
buildStatRows({
lang,
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
hwEncoder: engine.hw,
tonemap: engine.tonemap,
transport: level ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
measuredBps: fragMeterRef.current?.bitrate(),
audioLabel,
subLabel,
sessionStartS: sessionStartRef.current ?? 0,
sessionStartS: engine.sessionStart.current,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [
lang,
videoRef,
hlsRef,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
]);
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
return (
<div
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
expect(onAdvance).not.toHaveBeenCalled();
});
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
// An episode Plex has no number for (a special) is named by its title alone everywhere — a "?"
// told the viewer nothing, and the player's own header already shows the title alone.
it("an episode heading drops the numbers when Plex has none for it", async () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "e7",
kind: "episode",
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
episode_number: null,
});
renderCard();
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
expect(await screen.findByText("Westworld")).toBeTruthy();
expect(screen.getByText("The Adversary")).toBeTruthy();
expect(screen.queryByText(/\?/)).toBeNull();
});
});
+7 -2
View File
@@ -6,6 +6,7 @@ import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { cardImage } from "../lib/plexImage";
import { episodeNumbers } from "../lib/plexDetail";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
@@ -55,10 +56,14 @@ export function UpNextCard({
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
// Title: "Show · S2·E5" over the episode title for episodes (the show alone when Plex has no
// number for the episode); the plain title for movies/queue items.
const nums = next ? episodeNumbers(next) : null;
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
? nums
? `${next.show_title} · S${nums.season}·E${nums.episode}`
: next.show_title
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
@@ -286,3 +286,41 @@ describe("WatchPage honest failures", () => {
);
});
});
// A shared link opens where THIS browser stopped in it (the viewer has no account), and keeps the
// point as it plays — per link, so another link starts at its own place.
describe("WatchPage resume", () => {
function loaded(video: HTMLVideoElement, duration = 600) {
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
it("keeps where it stopped, and opens there on the next visit", async () => {
window.history.replaceState({}, "", "/watch/tok-resume");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const first = render(<WatchPage />);
await waitFor(() => expect(first.container.querySelector("video")).not.toBeNull());
const video = loaded(first.container.querySelector("video")!);
expect(video.currentTime).toBe(0);
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
cleanup();
const second = render(<WatchPage />);
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
expect(loaded(second.container.querySelector("video")!).currentTime).toBe(250);
cleanup();
window.history.replaceState({}, "", "/watch/tok-other");
const other = render(<WatchPage />);
await waitFor(() => expect(other.container.querySelector("video")).not.toBeNull());
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});
+22 -42
View File
@@ -1,9 +1,12 @@
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
const init: MediaMetadataInit = {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
};
try {
navigator.mediaSession.metadata = new MediaMetadata(init);
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
}
type Meta = {
needs_password: boolean;
title?: string | null;
@@ -95,13 +64,24 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
+6 -1
View File
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
</div>
);
}
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
return (
<div className={THUMB_BOX}>
<div className={`${THUMB_BOX} relative`}>
<MediaProgress pct={pct} />
{src ? (
<img
src={src}
+35 -3
View File
@@ -11,7 +11,11 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
// The key, and the episode a "next" line names after a colon (`{ name }`).
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
}),
};
});
@@ -155,8 +159,8 @@ describe("PlexPosterCard with a next episode", () => {
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
@@ -370,6 +374,34 @@ describe("PlexPosterCard on a shelf", () => {
});
});
// An episode Plex has no number for (a special) is named by its title alone, as the player's own
// header names it — the cards printed a question mark where the number was missing.
describe("an episode card with no number for the episode", () => {
const special: PlexCard = {
...episode(),
title: "The Special",
show_id: "sh",
show_title: "Show",
season_number: 0,
episode_number: null,
};
it("names a show's episode by its title alone", () => {
render(<PlexPosterCard card={special} onOpen={vi.fn()} />);
expect(screen.queryByText("plex.episodeLine")).toBeNull();
expect(screen.getByText("The Special")).toBeTruthy();
});
it("names the next episode by its title alone", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
render(
<PlexPosterCard card={show} next={special} onOpen={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy();
});
});
// A card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
+16 -13
View File
@@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react";
import { formatDecimal, formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail";
import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -318,7 +318,8 @@ export function PlexPosterCard({
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
// The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep);
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner =
@@ -361,8 +362,10 @@ export function PlexPosterCard({
<HoverBar
watched={actedWatched}
watchedLabel={
epCode
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
nextName
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", {
name: nextName,
})
: actedWatched
? t("plex.markUnwatched")
: t("plex.markWatched")
@@ -389,14 +392,14 @@ export function PlexPosterCard({
meta={
asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.episodeLine", {
season: card.season_number ?? "?",
episode: card.episode_number ?? "?",
title: card.title,
})}
{episodeNumbers(card)
? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title })
: card.title}
</div>
) : nextName ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.nextUp", { name: nextName })}
</div>
) : epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
@@ -741,8 +744,8 @@ export function EpisodeCard({
ep.show_title && (
<div className="truncate text-xs text-muted">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
{episodeNumbers(ep)
? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}`
: ""}
</div>
)
@@ -15,6 +15,8 @@ import {
type KeyScope,
} from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { useMediaKeys } from "../../lib/mediaKeys";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
pressH();
expect(descriptions()).toContain("keys.episode");
});
// The library player's F fills the screen with a video; an audio file has no picture, so the
// sheet over an audio file does not offer it. Through the real hook, which publishes the answer.
it("offers Fullscreen over a video and not over an audio file", () => {
function Player({ tag }: { tag: "video" | "audio" }) {
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaKeys(media);
return tag === "video" ? <video ref={setMedia} /> : <audio ref={setMedia} />;
}
const { unmount } = render(
<Host>
<Player tag="audio" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.playPause");
expect(descriptions()).not.toContain("keys.fullscreen");
unmount();
closeKeyboardHelp();
render(
<Host>
<Player tag="video" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.fullscreen");
});
});
@@ -85,3 +85,24 @@ describe("a dialog with an autoFocus child", () => {
stage.remove();
});
});
describe("a dialog whose own content goes fullscreen", () => {
it("stays where it is and keeps that content", () => {
// The library player is a <video> inside a dialog, and its fullscreen (the `f` key, or the
// video's own button) makes that video the fullscreen element. Moving the dialog's host INTO
// its own descendant is not a move the DOM allows: it threw, and the whole app went to the
// crash screen.
render(
<Modal initialFocus="close" title="Player" onClose={() => {}}>
<video aria-label="film" />
</Modal>,
);
const video = screen.getByLabelText("film");
const host = video.closest("body > div");
setFullscreen(video);
expect(video.isConnected).toBe(true);
expect(video.closest("body > div")).toBe(host);
});
});
+4 -2
View File
@@ -23,9 +23,10 @@ import { SURFACE } from "./surfaces";
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first
* the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no
* button). Required, so every dialog states its purpose instead of inheriting "the first
* focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action";
export type ModalFocus = "field" | "close" | "action" | "player";
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
@@ -101,6 +102,7 @@ export default function Modal({
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
initial: () =>
(initialFocus === "player" ? panelRef.current : null) ??
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
(closeRef.current?.offsetParent != null ? closeRef.current : null),
});
+26 -9
View File
@@ -13,6 +13,7 @@ import {
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -76,7 +77,7 @@ export function useDownloadActions({
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob) => void;
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
@@ -128,12 +129,28 @@ export function useDownloadActions({
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({
id: "play",
label: t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
});
// A started item continues, and can also start over — the card menus' words and glyph (C5).
const play = (job: DownloadJob): ActionMenuItem[] => {
const started = (job.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
@@ -200,7 +217,7 @@ export function useDownloadActions({
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? [play(job)] : []),
...(a.play ? play(job) : []),
{
id: "share",
label: t("downloads.actions.share"),
@@ -269,7 +286,7 @@ export function useDownloadActions({
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
@@ -1,4 +1,14 @@
[
{
"version": "0.102.0",
"date": "2026-10-08",
"new": [
"The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped."
],
"fixed": [
"Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream."
]
},
{
"version": "0.101.0",
"date": "2026-10-06",
@@ -1,4 +1,14 @@
[
{
"version": "0.102.0",
"date": "2026-10-08",
"new": [
"A könyvtár és a megosztott link lejátszója kezeli a billentyűzet és a rendszer médiagombjait, és onnan folytatja, ahol abbahagytad."
],
"fixed": [
"Plex-lejátszó: a böngésző Vissza gombja egyenként zárja be a videó fölötti paneleket, és csak a legvégén futó stáblista lép tovább a következő részre. Továbbá kisebb javítások a folytatási pontok és a stream leállítása körül."
]
},
{
"version": "0.101.0",
"date": "2026-10-06",
+3 -3
View File
@@ -121,9 +121,9 @@
"menu": "Media",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}",
"markNextWatched": "Mark {{name}} watched",
"markNextUnwatched": "Mark {{name}} unwatched",
"nextUp": "Next: {{name}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "Season {{n}}",
"specials": "Specials",
+3 -3
View File
@@ -121,9 +121,9 @@
"menu": "Média",
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}",
"markNextWatched": "{{name}} megnézettnek jelöl",
"markNextUnwatched": "{{name}} nem-nézettnek jelöl",
"nextUp": "Következik: {{name}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "{{n}}. évad",
"specials": "Különkiadások",
+28
View File
@@ -454,3 +454,31 @@ describe("uploadDownload's wire shape", () => {
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
});
});
// The library player re-reads its listings once its closing point is on the server: it cancelled
// their poll so an older answer could not put the point back, and only this callback brings them
// back. A beacon that never called it, or a slice that dropped it, left the lists stale with every
// test green.
describe("a beacon's settle callback", () => {
it("runs once the request is answered, and once it has failed", async () => {
const fetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockRejectedValueOnce(new TypeError("offline"));
const answered = vi.fn();
const failed = vi.fn();
beacon("/api/a", {}, answered);
beacon("/api/b", {}, failed);
await vi.waitFor(() =>
expect([answered.mock.calls.length, failed.mock.calls.length]).toEqual([1, 1]),
);
expect(fetch.mock.calls.map(([url]) => url).slice(-2)).toEqual(["/api/a", "/api/b"]);
});
it("is passed on by the library progress beacon", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 }));
const settled = vi.fn();
downloadsApi.saveDownloadProgressBeacon(7, 125.4, 600, settled);
await vi.waitFor(() => expect(settled).toHaveBeenCalledTimes(1));
});
});
+11 -6
View File
@@ -285,11 +285,13 @@ export function identityHeaders(): Record<string, string> {
};
}
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
export function beacon(url: string, body: Record<string, unknown>): void {
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
// still make: the resume checkpoints of the YouTube, Plex and library players, the Plex session stop,
// the diagnostics flush. A normal fetch is cancelled on unload and React effect cleanup doesn't run
// on a full reload, so without keepalive the last position (esp. right after a seek) would be lost.
// `onSettled`: the request has been answered (or failed) — for a caller whose cached view must be
// read again once the write is on the server.
export function beacon(url: string, body: Record<string, unknown>, onSettled?: () => void): void {
try {
void send(url, {
method: "POST",
@@ -297,7 +299,10 @@ export function beacon(url: string, body: Record<string, unknown>): void {
credentials: "include",
headers: { "Content-Type": "application/json", ...identityHeaders() },
body: JSON.stringify(body),
}).catch(() => {});
}).then(
() => onSettled?.(),
() => onSettled?.(),
);
} catch {
/* ignore */
}
+36 -1
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, uploadReq, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -97,6 +97,9 @@ export interface DownloadJob {
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
position_seconds?: number;
}
export interface ShareRecipient {
@@ -274,6 +277,38 @@ export const downloadsApi = {
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
saveDownloadProgress: (
id: number,
positionSeconds: number,
durationSeconds: number,
): Promise<{ ok: true; job_id: number; position_seconds: number }> =>
req(
`/api/downloads/${id}/progress`,
{
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true },
),
// The same, as a keepalive POST that survives the page going away (a reload, a closed tab).
saveDownloadProgressBeacon: (
id: number,
positionSeconds: number,
durationSeconds: number,
onSettled?: () => void,
): void =>
beacon(
`/api/downloads/${id}/progress`,
{
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
},
onSettled,
),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab
+11
View File
@@ -132,6 +132,8 @@ export interface PlexMarker {
type: "intro" | "credits";
start_s: number;
end_s: number;
/** Plex's own word that this credits block ends the item (see `lib/plexMarkers`). */
final: boolean;
}
// What the player names an audio/subtitle track from (`lib/vocab` trackLabel). Plex's own title
// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag.
@@ -412,6 +414,7 @@ export interface PlexFacets {
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession)
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
@@ -717,6 +720,14 @@ export const plexApi = {
method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}),
// The player no longer wants this HLS session: its ffmpeg stops now instead of at the idle reaper.
// A keepalive POST, so it also goes out from a page being unloaded. The server only stops it while
// it is still the viewer's registered session for the item.
plexStopSession: (id: string, sessionId: string): void =>
beacon(
`/api/plex/stream/${encodeURIComponent(id)}/session/${encodeURIComponent(sessionId)}/stop`,
{},
),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { api } from "../api";
// The session stop is a keepalive beacon: nothing answers it on screen, so a URL that no longer
// matches the server's route fails silently — every transcode the player leaves runs on until the
// idle reaper. The route is read from the server's source, as the other contract tests do.
afterEach(() => vi.restoreAllMocks());
function serverRoute(): RegExp {
// Anchored to this file, not the working directory, so the test holds wherever vitest starts.
const source = readFileSync(
resolve(__dirname, "../../../../backend/app/routes/plex/playback.py"),
"utf8",
);
const prefix = /APIRouter\(prefix="([^"]+)"/.exec(source)?.[1];
const path = /@router\.post\("(\/stream\/[^"]*\/stop)"\)/.exec(source)?.[1];
// If either is missing the route was renamed or moved — re-point this test, the rule stands.
expect(prefix, "router prefix").toBeDefined();
expect(path, "the session stop route").toBeDefined();
// Each path parameter becomes a group of its own NAME: two ids that swapped places still match a
// pattern of anonymous wildcards, and the server then finds no session to stop.
const pattern = (prefix! + path!).replace(/\{(\w+)\}/g, "(?<$1>[^/]+)");
return new RegExp(`^${pattern}$`);
}
it("sends the session stop to the route the server serves it on", () => {
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null));
api.plexStopSession("12345", "a1b2c3");
const url = String(fetch.mock.calls[0]![0]);
expect(url.match(serverRoute())?.groups).toEqual({ rating_key: "12345", session_id: "a1b2c3" });
expect(fetch.mock.calls[0]![1]).toMatchObject({ method: "POST", keepalive: true });
});
+4 -1
View File
@@ -95,7 +95,10 @@ export function useFullscreenPortalHost(): HTMLElement | null {
}, [host]);
useLayoutEffect(() => {
if (!host) return;
const parent = fsRoot ?? document.body;
// When the fullscreen element is the overlay's OWN content (a <video> inside a dialog, put in
// fullscreen by its key or its own button), it already shows; moving the host into its own
// descendant is a move the DOM refuses (HierarchyRequestError, the whole app crashed).
const parent = fsRoot && !host.contains(fsRoot) ? fsRoot : document.body;
if (host.parentNode === parent) return;
const focused = host.contains(document.activeElement)
? (document.activeElement as HTMLElement)
+62 -3
View File
@@ -55,6 +55,20 @@ afterEach(() => {
vi.clearAllMocks();
});
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
describe("useBackToExit back-ladder", () => {
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
const outer = vi.fn();
@@ -163,9 +177,9 @@ describe("closeOverlaysThenNavigate", () => {
}
});
// An overlay's Back callback is a Back STEP: the Plex player keeps its panels on one entry and peels
// one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`,
// Shift+W in the player closed the info panel and nothing else.
// An overlay's Back callback is a Back STEP, and the contract lets one entry hold several. When the
// Plex player kept its panels on one entry, a single call left the entry up and the navigation was
// dropped 1.5 s later — `i`, `d`, Shift+W in the player closed the info panel and nothing else.
it("asks again, a render apart, when one call peels only a step off the overlay", async () => {
const back = stubBack();
try {
@@ -329,3 +343,48 @@ describe("closeTopOverlayThenRun", () => {
}
});
});
// See useHistorySubview's back: a full tab can have dropped the entry under an overlay's own. The
// reconcile then has no entry of ours to step back to, and a back() would leave the document.
describe("an overlay whose entry has no entry of this document below it", () => {
it("drops its mark from the entry instead of stepping back out of the document", async () => {
const back = stubBack();
const layer = mountLayer(() => {});
await settle();
expect(window.history.state?._ov).toBeDefined();
stubNavigation([false]);
layer.unmount();
await settle();
expect(back).not.toHaveBeenCalled();
expect(window.history.state?._ov).toBeUndefined();
});
it("counts its own back()s still in flight: of two entries closing at once, only the first steps", async () => {
// Under the two overlay entries: one entry of this document, then another document's.
const landing: (() => void)[] = [];
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
landing.push(() =>
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })),
);
});
const lower = mountLayer(() => {});
const upper = mountLayer(() => {});
await settle();
stubNavigation([true, false]);
upper.unmount();
lower.unmount();
await settle();
expect(back).toHaveBeenCalledTimes(1);
act(() => landing.forEach((land) => land())); // the one back() lands, the books are square again
});
it("still steps back over its entry when the one below is this document's", async () => {
const back = stubBack();
const layer = mountLayer(() => {});
await settle();
stubNavigation([true]);
layer.unmount();
await settle();
expect(back).toHaveBeenCalledTimes(1);
});
});
+79 -3
View File
@@ -7,8 +7,8 @@
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview, type SubviewUrl } from "./history";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useBackToClose, useHistorySubview, type SubviewUrl } from "./history";
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors
// verbatim to ?v=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
@@ -27,7 +27,21 @@ const popTo = (search: string, state: unknown = null) =>
});
beforeEach(() => setUrl("/"));
afterEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
describe("useHistorySubview seed", () => {
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
@@ -170,3 +184,65 @@ describe("useHistorySubview entry", () => {
expect(window.history.state._sub).toEqual({ kind: "player" });
});
});
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
// left by a pushState with no user gesture — and that can be this page's own entry under the
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
// player started playing on its own).
describe("useHistorySubview back", () => {
it("goes back when the entry below is this document's", () => {
stubNavigation([true]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.back());
expect(back).toHaveBeenCalledTimes(1);
});
it("goes back as before where the browser has no Navigation API", () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.back());
expect(back).toHaveBeenCalledTimes(1);
});
// A sub-view left while an overlay of its own is still up — the Plex player's stats readout, a
// panel, the shortcuts sheet: the step back used to land as a USER Back, so the overlay listener
// spent it closing the top overlay and the sub-view stayed where it was.
it("closes the overlays standing over it first, then steps back out of the sub-view", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("player"));
const closed = vi.fn();
const holder: { unmount?: () => void } = {};
const layer = renderHook(() =>
useBackToClose(() => {
closed();
holder.unmount?.();
}),
);
holder.unmount = layer.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.state?._ov).toBeDefined();
act(() => result.current.back());
await waitFor(() => expect(result.current.view).toBe("root"));
expect(closed).toHaveBeenCalledTimes(1);
expect(window.history.state?._ov).toBeUndefined();
});
it("returns to the root in place when the entry below is another document's", () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
const before = window.history.length;
act(() => result.current.back());
expect(back).not.toHaveBeenCalled();
expect(result.current.view).toBe("root");
expect(window.history.state?._sub).toBe("root");
expect(window.location.search).toBe("");
expect(window.history.length).toBe(before);
});
});
+56 -8
View File
@@ -12,6 +12,7 @@
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
import { useLatest } from "./useLatest";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
@@ -96,7 +97,29 @@ export function rememberNavPosition(): Record<string, unknown> {
return st;
}
// --- Is the entry a back() lands on still ours? --------------------------------------------
// Chrome keeps 50 entries a tab. When the list is full a new entry drops the OLDEST "skippable" one —
// an entry the page left by a pushState without a user gesture (an auto-skip countdown, a card that
// opened on its own) — not the oldest entry, so it can be this page's own entry under a sub-view.
// A programmatic back() then leaves the document for whatever older page sits below (measured
// 2026-10-07: an older entry whose restored Plex player started playing on its own). The Navigation
// API says which document the entry a back() lands on belongs to — counting the back()s of ours still
// in flight; a browser without it keeps the old assumption that our entries are all still there.
interface NavigationLike {
currentEntry: { index: number } | null;
entries: () => readonly { sameDocument: boolean }[];
}
/** Would one more history.back() still land in this document? */
export function backStaysInDocument(): boolean {
const nav = (window as { navigation?: NavigationLike }).navigation;
const at = nav?.currentEntry?.index;
if (nav == null || at == null) return true;
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true;
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
export interface SubviewUrl<T> {
@@ -151,7 +174,12 @@ export function useHistorySubview<T>(
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
// module functions and the setter, so there is nothing left to churn on.
const open = useCallback(
// Both move the module to another view, so both are navigations: they close the overlays standing
// over it first (`closeOverlaysThenNavigate`, synchronous when none is open). A step taken under an
// open overlay landed as a USER Back — the overlay listener spent it closing the top overlay, so
// leaving the Plex player with its stats readout up only closed the readout — and a push or a
// replace on top of an overlay's entry is undone when that entry is reclaimed.
const go = useCallback(
(next: T, opts?: { replace?: boolean }) => {
setView(next);
const kept = url?.entry ? url.entry(next) : next;
@@ -175,7 +203,21 @@ export function useHistorySubview<T>(
},
[url],
);
const back = useCallback(() => window.history.back(), []);
const open = useCallback(
(next: T, opts?: { replace?: boolean }) => closeOverlaysThenNavigate(() => go(next, opts)),
[go],
);
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
// the root view in place — a step up the module, not out of the app.
const rootRef = useLatest(root);
const back = useCallback(
() =>
closeOverlaysThenNavigate(() => {
if (backStaysInDocument()) window.history.back();
else go(rootRef.current, { replace: true });
}),
[go, rootRef],
);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, back }), [view, open, back]);
@@ -228,8 +270,14 @@ function reconcileHistory(): void {
}
while (historyDepth > want) {
historyDepth--;
pendingProgrammatic++;
window.history.back();
if (backStaysInDocument()) {
pendingProgrammatic++;
window.history.back();
} else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
}
}
syncWaiters.forEach((cb) => cb());
}
@@ -267,10 +315,10 @@ function historyInSync(): boolean {
// render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today,
// but a future "discard your changes?" guard must not be navigated out from under.
const CLOSE_TIMEOUT_MS = 1500;
// An overlay's Back callback is a Back STEP, and one entry can hold several steps: the Plex player's
// panels share a single entry and Back peels one per press. Navigating over it asks again, a render
// apart (the callback reads what the last one closed), until the entry is gone or the timeout drops
// the command.
// An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
// call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
// navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
// reads what the last one closed), until the entry is gone or the timeout drops the command.
const RECLOSE_MS = 50;
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
+570
View File
@@ -0,0 +1,570 @@
// The Plex player's session machine, without React, hls.js or a media pipeline: every race it exists
// to win, driven step by step. PlexPlayer's DOM tests hold the same behaviour through the component.
import { describe, it, expect, vi } from "vitest";
import type { PlexPlaySession } from "./api";
import {
HlsEngine,
MAX_SESSION_RECLAIMS,
RECLAIM_WINDOW_MS,
type EngineDeps,
type HlsHandlers,
type HlsPort,
type SessionParams,
} from "./hlsEngine";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
/** A <video> stand-in: the media calls the engine makes, and events the test fires. */
class FakeMedia extends EventTarget {
paused = true;
currentTime = 0;
src = "";
seekEnd = 0;
plays = 0;
get seekable() {
const end = this.seekEnd;
return { length: end > 0 ? 1 : 0, end: () => end };
}
play() {
this.plays++;
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {}
removeAttribute(name: string) {
if (name === "src") this.src = "";
}
fire(type: string) {
this.dispatchEvent(new Event(type));
}
}
class FakeHls implements HlsPort {
loaded: string | null = null;
destroyed = false;
audioTrack = 0;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
}
destroy() {
this.destroyed = true;
}
getAudioTrack() {
return this.audioTrack;
}
setAudioTrack(track: number) {
this.audioTrack = track;
}
levelInfo() {
return { index: 0, total: 1 };
}
lost404() {
this.handlers.onError({
fatal: true,
network: true,
status: 404,
type: "networkError",
details: "fragLoadError",
});
}
}
interface Ask {
id: string;
startAt: number;
params: SessionParams;
resolve(s: PlexPlaySession): Promise<void>;
reject(e: unknown): Promise<void>;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(over: Partial<EngineDeps> = {}) {
const media = new FakeMedia();
const asks: Ask[] = [];
const hlses: FakeHls[] = [];
const stops: [string, string][] = [];
let clock = 1_000_000;
const state = { position: 0, duration: 3000, params: { multi: false, audio: null, aoff: 0 } };
const engine = new HlsEngine({
media: () => media as unknown as HTMLMediaElement,
requestSession: (id, startAt, params) =>
new Promise<PlexPlaySession>((res, rej) => {
asks.push({
id,
startAt,
params,
resolve: async (s) => {
res(s);
await flush();
},
reject: async (e) => {
rej(e);
await flush();
},
});
}),
stopSession: (id, sid) => void stops.push([id, sid]),
createHls: (h) => {
const hls = new FakeHls(h);
hlses.push(hls);
return hls;
},
params: () => state.params,
position: () => state.position,
duration: () => state.duration,
now: () => clock,
statusOf: (e) => (e as { status?: number }).status,
...over,
});
return {
engine,
media,
asks,
hlses,
stops,
state,
advance: (ms: number) => void (clock += ms),
};
}
const hls = (id: string, start_s = 0, sid = `s-${id}-${start_s}`): PlexPlaySession => ({
mode: "hls",
url: `/hls/${id}/index.m3u8`,
start_s,
id: sid,
});
const direct = (id: string): PlexPlaySession => ({
mode: "direct",
url: `/media/${id}`,
start_s: 0,
});
describe("one request per item in flight", () => {
it("queues a restart asked for while the start is out, and sends only the LAST target", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
for (let t = 100; t <= 2000; t += 100) engine.seek("A", t);
expect(asks.map((a) => a.startAt)).toEqual([0]);
await asks[0]!.resolve(hls("A"));
expect(asks.map((a) => a.startAt)).toEqual([0, 2000]);
});
it("drops the stale answer it queued behind — it never attaches", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
engine.seek("A", 500);
await asks[0]!.resolve(hls("A", 0));
expect(hlses).toHaveLength(0);
await asks[1]!.resolve(hls("A", 500));
expect(hlses).toHaveLength(1);
expect(hlses[0]!.loaded).toBe("/hls/A/index.m3u8?t=500");
expect(media.src).toBe("");
});
it("still sends the queued target when the in-flight request fails, and shows no error for it", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 700);
await asks[0]!.reject({ status: 500 });
expect(asks.map((a) => a.startAt)).toEqual([0, 700]);
expect(engine.getSnapshot().error).toBeNull();
});
it("sends another item's request at once, and drops the first item's queue", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
engine.start("B", 0);
expect(asks.map((a) => [a.id, a.startAt])).toEqual([
["A", 0],
["B", 0],
]);
await asks[0]!.resolve(hls("A"));
await asks[1]!.resolve(direct("B"));
expect(asks).toHaveLength(2); // A's 900 is gone with A, whichever answer settles first
});
it("never sends a queued target of an item the viewer left before the next one started", async () => {
// The next item's detail may still be loading: nothing replaced the queue yet.
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
await asks[0]!.resolve(hls("A"));
expect(asks).toHaveLength(1);
});
it("reads the track parameters when the queued request is SENT", async () => {
const { engine, asks, state } = setup();
engine.start("A", 100);
engine.start("A", 100); // an audio switch while the start is out
state.params = { multi: false, audio: 1, aoff: 0 } as never;
await asks[0]!.resolve(hls("A", 100));
expect(asks[1]!.params.audio).toBe(1);
});
});
describe("answers that land late", () => {
it("are dropped after the viewer moved to another item — and the server session is stopped", async () => {
const { engine, asks, hlses, stops } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(hlses).toHaveLength(0);
expect(stops).toEqual([["A", "sA"]]);
});
it("are dropped after the player closed", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
engine.destroy(media as unknown as HTMLMediaElement);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("");
});
it("land again once a StrictMode remount revived the engine", async () => {
const { engine, asks, media } = setup();
engine.destroy(media as unknown as HTMLMediaElement);
engine.revive();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("/media/A");
});
it("a superseded direct item's loadedmetadata does not seek the next one", async () => {
const { engine, asks, media } = setup();
engine.start("A", 1200);
await asks[0]!.resolve(direct("A"));
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
media.fire("loadedmetadata");
expect(media.currentTime).toBe(0);
});
});
describe("the server session", () => {
it("is stopped when the viewer leaves the item, and when the player closes", async () => {
const { engine, asks, stops, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
engine.start("B", 0);
await asks[1]!.resolve(hls("B", 0, "sB"));
engine.destroy(media as unknown as HTMLMediaElement);
expect(stops).toEqual([
["A", "sA"],
["B", "sB"],
]);
});
it("is not stopped by a restart of the same item — the server replaces it itself", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000, "s2"));
expect(stops).toEqual([]);
});
it("is stopped when a direct stream of the same item replaces it", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.start("A", 0); // an A/V offset back at 0 plays the file directly
await asks[1]!.resolve(direct("A"));
expect(stops).toEqual([["A", "s1"]]);
});
it("is stopped when Safari plays it natively too (no hls.js)", async () => {
const { engine, asks, stops, media } = setup({ createHls: () => null });
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(media.src).toBe("/hls/A/index.m3u8");
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
});
it("is stopped on an unload, but not when the page goes into the back/forward cache", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.pageHidden(true);
expect(stops).toEqual([]);
engine.pageHidden(false);
expect(stops).toEqual([["A", "sA"]]);
});
});
describe("a failed session request", () => {
it.each([
[404, "notFound"],
[501, "transcode"],
[500, "generic"],
] as const)("%s says %s", async (status, error) => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status });
expect(engine.getSnapshot().error).toBe(error);
});
it("keeps the old stream's clock after a failed seek-restart, not after a failed move", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
engine.seek("A", 2000);
expect(engine.clockLive).toBe(false);
await asks[1]!.reject({ status: 500 });
expect(engine.clockLive).toBe(true);
engine.leaveItem();
engine.start("B", 0);
await asks[2]!.reject({ status: 404 });
expect(engine.clockLive).toBe(false);
});
});
describe("a session the server no longer has (404)", () => {
it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
state.position = 1299;
hlses[0]!.lost404();
expect(asks[1]!.startAt).toBe(1299);
});
it(`gives up after ${MAX_SESSION_RECLAIMS} reclaims inside the window, and says so`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("rolls the window: losses closer than it keep counting, however slow", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS * 0.6);
}
expect(engine.getSnapshot().error).toBe("generic");
});
it("starts over for a loss long after the last one, and for a new item", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i < 4; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS + 1);
}
expect(engine.getSnapshot().error).toBeNull();
const fresh = setup();
fresh.engine.start("A", 0);
for (let i = 0; i < MAX_SESSION_RECLAIMS; i++) {
await fresh.asks[i]!.resolve(hls("A"));
fresh.hlses[i]!.lost404();
}
fresh.engine.newItem();
await fresh.asks[MAX_SESSION_RECLAIMS]!.resolve(hls("A"));
fresh.hlses[MAX_SESSION_RECLAIMS]!.lost404();
expect(fresh.engine.getSnapshot().error).toBeNull();
});
it("keeps the start's own intent to play when it was lost before it ever played", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.lost404(); // media is still paused: not the viewer's pause
await asks[1]!.resolve(hls("A", 600));
hlses[1]!.handlers.onManifestParsed();
expect(media.plays).toBe(1);
});
});
describe("play intent while a session is asked for", () => {
it("goes into the pending session, and never plays the outgoing media", async () => {
const { engine, asks, media, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.fire("loadedmetadata");
expect(media.paused).toBe(false);
engine.seek("A", 2000, false); // a restart while paused-by-request
expect(engine.requestPlay(false)).toBe(true);
expect(media.paused).toBe(true);
const plays = media.plays;
expect(engine.requestPlay(true)).toBe(true);
expect(media.plays).toBe(plays); // the outgoing stream stays silent
expect(engine.shownPlaying()).toBe(true);
await asks[1]!.resolve(hls("A", 2000));
hlses[0]!.handlers.onManifestParsed();
expect(media.plays).toBe(plays + 1);
});
});
describe("seeking", () => {
it("a restart asked for before the new session has played keeps that session's intent to play", async () => {
// Attaching a new session empties the media: it reads paused until hls.js parses and plays. A
// seek in that window read the paused element as "the viewer paused", and the next session
// attached paused at 0:00 (measured: a burst of ← ending in a frozen frame).
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0));
expect(media.paused).toBe(true); // attached, not played yet
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000));
hlses[1]!.handlers.onManifestParsed();
expect(media.paused).toBe(false);
});
it("is native inside the attached session's loaded region", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 650);
expect(asks).toHaveLength(1);
expect(media.currentTime).toBeCloseTo(650 - (600 - HLS_SUB_LAG));
});
it("restarts past it, and while the clock is not the current session's", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 900);
expect(asks.map((a) => a.startAt)).toEqual([0, 900]);
engine.seek("A", 650); // inside the OLD buffer, but a restart is pending
await asks[1]!.resolve(hls("A", 900));
expect(asks.map((a) => a.startAt)).toEqual([0, 900, 650]);
});
it("clamps the target to the item", () => {
const { engine } = setup();
expect(engine.clamp(-5)).toBe(0);
expect(engine.clamp(99_999)).toBe(2999);
});
});
describe("the session's clock", () => {
it("folds hls.js's lag into a resumed HLS session's start, not a direct one's", async () => {
const { engine, asks } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
expect(engine.getSnapshot().sessionK).toBeCloseTo(600 - HLS_SUB_LAG);
engine.start("A", 0);
await asks[1]!.resolve(direct("A"));
expect(engine.getSnapshot().sessionK).toBe(0);
});
it("is not trusted between asking for a session and its answer", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.currentTime = 50;
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBe(50);
engine.releaseClock();
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBeNull();
});
it("answers 'where the viewer is' with the asked start before playback has ticked", () => {
const { engine, state } = setup();
engine.start("A", 1200);
expect(engine.playhead()).toBe(1200);
state.position = 1250;
expect(engine.playhead()).toBe(1250);
});
});
describe("audio on a multi-rendition session", () => {
it("restores the chosen track once hls.js lists the renditions, and switches client-side", async () => {
const { engine, asks, hlses, state } = setup();
state.params = { multi: true, audio: 1, aoff: 0 } as never;
expect(engine.setAudioTrack(1)).toBe(false); // nothing attached yet: the caller restarts
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onAudioTracksUpdated();
expect(hlses[0]!.audioTrack).toBe(1);
expect(engine.setAudioTrack(0)).toBe(true);
expect(hlses[0]!.audioTrack).toBe(0);
expect(asks).toHaveLength(1);
});
});
it("tells React only when the snapshot really changed", async () => {
const { engine, asks } = setup();
const seen = vi.fn();
engine.subscribe(seen);
engine.start("A", 0);
const after = seen.mock.calls.length;
engine.start("A", 0); // same ready/error/transcode — nothing new to render
expect(seen.mock.calls.length).toBe(after);
await asks[0]!.resolve(direct("A"));
});
describe("an item the viewer comes back to while its request is still out", () => {
it("waits for that request instead of sending a second one beside it (A → B → A)", async () => {
// The in-flight request was tracked in one slot: B's request took it, and the way back to A
// sent A2 next to the A1 still out — two spawns for one item, and the loser's answer could
// name the winner's session for the stop.
const { engine, asks } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
engine.leaveItem();
engine.start("A", 0);
expect(asks.map((a) => a.id)).toEqual(["A", "B"]);
await asks[0]!.resolve(hls("A", 0, "s1"));
expect(asks.map((a) => a.id)).toEqual(["A", "B", "A"]);
});
});
describe("a session that was replaced before its manifest parsed", () => {
it("neither marks the player ready nor plays the outgoing media", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.seek("A", 900); // nothing seekable yet: a restart
const plays = media.plays;
hlses[0]!.handlers.onManifestParsed(); // the replaced instance parses late
expect(engine.getSnapshot().ready).toBe(false);
expect(media.plays).toBe(plays);
});
});
describe("a pause given after the answer, before the media can play", () => {
it("stands: neither the manifest nor the first canplay plays it again", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.requestPlay(false);
hlses[0]!.handlers.onManifestParsed();
expect(media.paused).toBe(true);
media.fire("canplay");
expect(media.paused).toBe(true);
});
it("stands after the manifest played it too", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
engine.requestPlay(false);
media.fire("canplay");
expect(media.paused).toBe(true);
});
});
+556
View File
@@ -0,0 +1,556 @@
import type { PlexPlaySession } from "./api";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
import type { HlsLevelInfo } from "./plexStats";
// The Plex player's session machine: which server session the <video> is playing, what a seek or a
// track change asks the server for, and which answers still belong to anything. Plain TypeScript with
// its collaborators injected (the session API, hls.js behind `HlsPort`, the media element), so every
// race it exists to win is a unit test without a DOM (hlsEngine.test.ts); PlexPlayer only renders it.
//
// The server keeps ONE HLS session per viewer per item and kills the previous ffmpeg on every new
// request for it. So the engine keeps at most one request per item in flight: a seek, track change or
// reclaim asked for while one is out replaces the QUEUED target, and the queue is sent once when the
// in-flight answer settles — that answer is then stale and dropped. Each request used to go out at
// once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg
// spawns, and five seconds before the picture moved again.
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2;
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
// the first fragment of any session, and a reclaimed session essentially always delivers one —
// `start_session` does not return until its first segment exists. So both cases `onHlsError` names
// restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
export const RECLAIM_WINDOW_MS = 120_000;
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
/** What the player renders from the engine. A new object whenever any of it changes. */
export interface EngineSnapshot {
/** The current session's media can play (its manifest parsed / its metadata loaded). */
ready: boolean;
error: EngineError | null;
/** The current session re-encodes the video: its cold start is honestly slower ("Preparing…"). */
transcode: boolean;
/** The current session's real media start, lag-compensated — the subtitle shift keys off it. */
sessionK: number;
}
/** A fatal-or-not hls.js error, reduced to what the engine decides on. */
export interface HlsFailure {
fatal: boolean;
network: boolean;
status: number | null;
type: string;
details: string;
}
/** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */
export interface HlsPort {
load(url: string, media: HTMLMediaElement): void;
destroy(): void;
getAudioTrack(): number;
setAudioTrack(track: number): void;
levelInfo(): HlsLevelInfo;
}
export interface HlsHandlers {
onManifestParsed(): void;
onAudioTracksUpdated(): void;
onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void;
onError(e: HlsFailure): void;
}
/** The track parameters a session request carries, read when it is SENT (a queued request goes out
* with whatever the viewer has picked by then). */
export interface SessionParams {
/** ≥2 audio tracks: one multi-rendition session, audio switched client-side. */
multi: boolean;
/** The single-rendition session's audio ordinal (ignored when `multi`). */
audio: number | null;
/** The A/V offset, seconds. */
aoff: number;
}
export interface EngineDeps {
media(): HTMLMediaElement | null;
requestSession(id: string, startAt: number, p: SessionParams): Promise<PlexPlaySession>;
/** Tell the server a session is no longer wanted (its ffmpeg stops at once). Fire-and-forget. */
stopSession(id: string, sessionId: string): void;
/** A new hls.js instance, or null where hls.js cannot run (the media plays the URL natively). */
createHls(handlers: HlsHandlers): HlsPort | null;
params(): SessionParams;
/** Where the viewer is, absolute seconds (0 = not ticked yet). */
position(): number;
/** The item's duration, seconds (0 = unknown). */
duration(): number;
now(): number;
/** An HTTP status from a failed session request (undefined = not an HTTP error). */
statusOf(e: unknown): number | undefined;
/** A request for a session went out: the player clears its stall spinner. */
onLoadStart?(): void;
onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void;
onHlsError?(e: HlsFailure): void;
}
/** Play intent while a session request is in flight. `said`: the viewer changed it since. */
interface Intent {
play: boolean;
said?: boolean;
}
// What an engine does before `connect`: nothing. It is created during render, where its owner may not
// hand it anything that reads a ref, and connected from a layout effect before any effect can use it.
const UNCONNECTED: EngineDeps = {
media: () => null,
requestSession: () => new Promise(() => {}),
stopSession: () => {},
createHls: () => null,
params: () => ({ multi: false, audio: null, aoff: 0 }),
position: () => 0,
duration: () => 0,
now: () => 0,
statusOf: () => undefined,
};
interface Request {
id: string;
startAt: number;
intent: Intent;
gen: number;
}
export class HlsEngine {
/** The absolute offset the attached session begins at (a ref-shaped box: hooks read `.current`). */
readonly sessionStart = { current: 0 };
/** Which encoder the current transcode ran: true = VAAPI, false = libx264, null = not a transcode. */
hw: boolean | null = null;
/** The current HW transcode tone-mapped HDR→SDR on the GPU. */
tonemap: boolean | null = null;
private deps: EngineDeps;
private snapshot: EngineSnapshot = { ready: false, error: null, transcode: false, sessionK: 0 };
private readonly listeners = new Set<() => void>();
private alive = true;
// Which request is the current one. Everything after a request's `await` checks it: an answer
// that lands after a newer request, an item change or the player's close is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to
// the detached <video> after Back, never destroyed, keeping the server session warm.
private gen = 0;
private hls: HlsPort | null = null;
// The server session the media is playing (HLS, by hls.js or natively): what `stopSession` names on
// the way out.
private attachedSession: { id: string; sessionId: string } | null = null;
// Removes the media listeners the current session put on the <video>. A newer session, an item
// change and the close all call it: the generation guards the `await`, and these fire LATER — a
// superseded direct-play item's loadedmetadata used to run on the next item's media and seek it to
// the old item's resume point, or start the old media playing under the new title.
private detachSession: () => void = () => {};
// Whether the media's clock belongs to the session `sessionStart` describes. False from the moment
// a session is asked for until it has answered: the media still attached meanwhile is the OLD one,
// so `sessionStart + currentTime` was the previous item's position — sent as the new item's subtitle
// position and saved as its progress, or, during a seek-restart, the pre-seek spot over the target.
private live = false;
// The play intent of the session being asked for, null while none is out. Until the answer
// attaches, the media is the OUTGOING one, and a play() there sounded the old stream while the new
// one still attached paused.
private pending: Intent | null = null;
// The item whose session is attached to the media right now (see the failure path).
private attachedId: string | null = null;
// The attached session's play intent, which stands until it is ready (see `wantsPlay`).
private attachedIntent: Intent | null = null;
// The request out for each item. Per item, not one slot: another item's request took the slot, and
// the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
// Where the current — or still in-flight — session was ASKED to start. Written before the request
// goes out, so "where the viewer is" has an answer before the first timeupdate.
private loadStart = 0;
constructor(deps: EngineDeps = UNCONNECTED) {
this.deps = deps;
}
/** Hand the engine its collaborators (see UNCONNECTED). */
connect(deps: EngineDeps): void {
this.deps = deps;
}
// --- the React side ------------------------------------------------------------------------
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getSnapshot = (): EngineSnapshot => this.snapshot;
private set(patch: Partial<EngineSnapshot>): void {
const next = { ...this.snapshot, ...patch };
const same = (Object.keys(next) as (keyof EngineSnapshot)[]).every(
(k) => next[k] === this.snapshot[k],
);
if (same) return;
this.snapshot = next;
this.listeners.forEach((fn) => fn());
}
/** The media's clock is the current session's (see `live`). */
get clockLive(): boolean {
return this.live;
}
/** The video level hls.js plays, or null for a direct stream. */
levelInfo(): HlsLevelInfo | null {
return this.hls ? this.hls.levelInfo() : null;
}
/** The player mounted (again — StrictMode replays a mount): answers may land on it once more. */
revive(): void {
this.alive = true;
}
/** The player closed: nothing in flight lands any more, the stream and its server session go,
* and the <video> stops — a late play() must not leave audio playing on a detached element. The
* element is the caller's: React has detached its ref by the time an unmount cleanup runs. */
destroy(media: HTMLMediaElement | null): void {
this.alive = false;
this.leaveItem();
if (!media) return;
try {
media.pause();
media.removeAttribute("src");
media.load();
} catch {
/* ignore */
}
}
/** The page is being unloaded for good (not kept in the back/forward cache): the server session
* stops now, since no effect cleanup runs on an unload. */
pageHidden(persisted: boolean): void {
const s = this.attachedSession;
if (persisted || !s) return;
this.attachedSession = null;
this.deps.stopSession(s.id, s.sessionId);
}
/** A fresh item is a fresh incident: the reclaim budget must not carry over from the previous one
* (the player stays mounted across a queue advance). */
newItem(): void {
this.reclaims = { n: 0, at: 0 };
}
/** Leaving the item: a session still in flight for it must not land after it, its stream goes,
* and the server stops producing it. */
leaveItem(): void {
this.gen++; // a queued request of this item is now stale, and is never sent
this.detachSession();
this.detachSession = () => {};
this.dropHls();
}
/** The viewer is moving to another item: the media's clock stops being trusted at once (the
* timeupdate the outgoing pause() queues lands after the reset and would hand the old position on). */
releaseClock(): void {
this.live = false;
}
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). */
start(id: string, startAt: number, play = true): void {
if (!this.deps.media()) return;
const gen = ++this.gen;
this.detachSession();
this.detachSession = () => {};
this.live = false;
const intent: Intent = { play };
this.pending = intent;
this.loadStart = startAt;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
const req: Request = { id, startAt, intent, gen };
if (this.inFlight.has(id)) this.queued = req;
else this.send(req);
}
private send(req: Request): void {
this.queued = null;
this.inFlight.set(req.id, req);
void this.deps.requestSession(req.id, req.startAt, this.deps.params()).then(
(sess) => this.answered(req, sess),
(e: unknown) => this.failed(req, e),
);
}
private current(req: Request): boolean {
return req.gen === this.gen && this.alive;
}
/** The in-flight request settled: the queued one goes now, unless it is stale too (the viewer
* left the item, or the player closed). */
private settledInFlight(req: Request): void {
if (this.inFlight.get(req.id) === req) this.inFlight.delete(req.id);
const next = this.queued;
if (!next || this.inFlight.has(next.id)) return;
this.queued = null;
if (this.alive && next.gen === this.gen) this.send(next);
}
private failed(req: Request, e: unknown): void {
this.settledInFlight(req);
if (!this.current(req)) return; // superseded or left: not this request's error to show
if (this.pending === req.intent) this.pending = null;
// A failed seek-restart leaves the old session attached and its clock still true; a failed move
// to another item leaves the previous item's media, which is not this item's clock.
this.live = this.attachedId === req.id;
// Say WHY playback can't start instead of an eternal spinner. 404 = the physical file isn't
// reachable (a missing media mount or a wrong path map); 501 = the codec needs a full transcode.
const status = this.deps.statusOf(e);
this.set({ error: status === 501 ? "transcode" : status === 404 ? "notFound" : "generic" });
}
private answered(req: Request, sess: PlexPlaySession): void {
this.settledInFlight(req);
if (!this.current(req)) {
// Nobody will play it: say so, or its ffmpeg runs on until the reaper. The server matches the
// id, so a newer session for the same item that has replaced it meanwhile is left alone.
if (sess.mode === "hls" && sess.id) this.deps.stopSession(req.id, sess.id);
return;
}
const media = this.deps.media();
if (!media) return;
const { intent, startAt, id } = req;
const isTranscode = sess.mode === "hls" && !!sess.transcode;
this.hw = isTranscode ? (sess.hw ?? false) : null;
this.tonemap = isTranscode ? (sess.tonemap ?? false) : null;
// hls.js plays a non-zero-start copyts stream ~1 s BEHIND the media clock (see HLS_SUB_LAG); fold
// that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct play (start_s 0) uses the file's real clock.
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
this.sessionStart.current = trueStart;
// From here the media's clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
this.live = true;
this.attachedId = id;
this.attachedIntent = intent;
if (this.pending === intent) this.pending = null;
this.set({ transcode: isTranscode, sessionK: trueStart });
// Belt and braces: the play() on MANIFEST_PARSED / loadedmetadata can be rejected when the media
// is not ready yet (slow first segments over a proxy), leaving the video paused after a seek the
// viewer was watching through. Retried once the element can play; the intent is read when it
// fires, since the viewer may have asked for play or pause meanwhile (`requestPlay` keeps it).
this.listen(media, req, "canplay", () => {
if (intent.play && media.paused) media.play().catch(() => {});
});
// The server replaced the previous HLS session for this item already — unless this answer is a
// direct stream (an A/V offset back at 0), which replaces nothing there.
this.dropHls(sess.mode === "hls" ? id : undefined);
// Named for the stop on the way out whoever plays it: hls.js, or Safari's native HLS below.
if (sess.mode === "hls" && sess.id) this.attachedSession = { id, sessionId: sess.id };
const hls = sess.mode === "hls" ? this.deps.createHls(this.hlsHandlers(req, media)) : null;
if (hls) {
this.hls = hls;
hls.load(`${sess.url}?t=${Math.floor(startAt)}`, media);
return;
}
// A direct file (or Safari's native HLS).
media.src = sess.url;
media.load();
this.listen(media, req, "loadedmetadata", () => {
if (sess.mode === "direct" && startAt > 0) media.currentTime = startAt;
this.set({ ready: true });
if (intent.play) media.play().catch(() => {});
});
}
/** A once-listener on the media that runs only while `req` is the current session. */
private listen(media: HTMLMediaElement, req: Request, type: string, fn: () => void): void {
const guarded = () => {
if (this.current(req)) fn();
};
media.addEventListener(type, guarded, { once: true });
const prev = this.detachSession;
this.detachSession = () => {
prev();
media.removeEventListener(type, guarded);
};
}
/** Destroy the hls.js instance, and stop the attached server session unless it belongs to
* `keepFor` (the server replaces a viewer's session for one item by itself). */
private dropHls(keepFor?: string): void {
const s = this.attachedSession;
this.attachedSession = null;
if (s && s.id !== keepFor) this.deps.stopSession(s.id, s.sessionId);
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
}
private hlsHandlers(req: Request, media: HTMLMediaElement): HlsHandlers {
return {
// Only the current session's: an instance replaced before its manifest parsed (a restart asked
// for right after an attach) marked the player ready while the next session was still being
// asked for — so that one then attached past `wantsPlay`'s not-ready guard — and played the
// outgoing media.
onManifestParsed: () => {
if (!this.current(req)) return;
this.set({ ready: true });
if (req.intent.play) media.play().catch(() => {});
},
// The selected audio is restored once hls.js has the renditions: on MANIFEST_PARSED the list
// was not populated yet, the choice was dropped, and the default track played under the
// restored selection.
onAudioTracksUpdated: () => {
const p = this.deps.params();
if (!p.multi || !this.hls) return;
const want = p.audio ?? 0;
if (this.hls.getAudioTrack() === want) return;
try {
this.hls.setAudioTrack(want);
} catch {
/* out of range — ignore */
}
},
onFragLoaded: (frag) => this.deps.onFragLoaded?.(frag),
onError: (e) => this.onHlsError(req, media, e),
};
}
// A fatal HLS error (the session died, the file became unreadable) would otherwise leave the
// spinner up forever — it is said instead.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two
// requests for one item: the server's LAST spawn wins). The server answers a superseded session
// exactly so the client asks again; the reclaim is that asking.
//
// Bounded by the budget, not by whether the session got as far as playing: that belongs to ONE
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers
// 404 for "that segment is not there YET" — indistinguishable on the wire — so a transcode falling
// behind realtime restarted ffmpeg forever behind a spinner that never became an error.
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return;
if (e.network && e.status === 404) {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n < MAX_SESSION_RECLAIMS) {
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media));
return;
}
}
this.set({ error: "generic" });
}
/** Where the viewer is: the ticked position, or — before playback has ticked (a session still
* loading, or a resumed one that has stayed paused) — where the session was ASKED to start.
* Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00
* from 0:00, and the next checkpoint saved that. */
playhead(): number {
const p = this.deps.position();
return p > 0 ? p : this.loadStart;
}
/** The absolute time of the media's clock, or null while it is not the current session's. */
absTime(media: HTMLMediaElement): number | null {
return this.live ? this.sessionStart.current + media.currentTime : null;
}
/** A seek target inside the item: not before 0, not past its last second. */
clamp(target: number): number {
const dur = this.deps.duration() || target;
return Math.max(0, Math.min(target, dur - 1));
}
/** Seek to absolute `target` (clamped): natively inside the attached session's loaded region,
* else a session restart there. `play`: the caller's intent across the seek; else what the media
* was doing. */
seek(id: string, target: number, play?: boolean): void {
const media = this.deps.media();
const T = this.clamp(target);
if (!media) return;
const wasPlaying = play ?? this.wantsPlay(media);
const rel = T - this.sessionStart.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending
// restart current, and its answer then jumped to the earlier target.
const end = media.seekable.length ? media.seekable.end(0) : 0;
if (this.live && rel >= 0 && rel <= end + 0.5) {
media.currentTime = rel; // keeps playing on its own
if (play) media.play().catch(() => {});
} else {
this.start(id, T, wasPlaying); // only auto-resume if it was playing
}
}
/** Play or pause. While a session is being asked for, that is the pending session's intent: the
* outgoing media is never played, only paused if the viewer asks for a pause. Returns true when
* it went into the intent — the caller then shows `want` itself, the media has not changed. */
requestPlay(want: boolean): boolean {
const media = this.deps.media();
if (!media) return false;
const pending = this.pending;
if (pending) {
pending.play = want;
pending.said = true;
if (!want && !media.paused) media.pause();
return true;
}
// The attached session's intent follows: its manifest and first canplay still play by it, and a
// pause given before they fired was played over.
if (this.attachedIntent) this.attachedIntent.play = want;
if (want) media.play().catch(() => {});
else media.pause();
return false;
}
/** Whether the viewer wants playback, for a restart that carries it over: a session still being
* asked for has its own intent; an attached one keeps the intent it was asked with until it is
* ready, because attaching empties the media — it reads paused until hls.js has parsed and played,
* and a seek or a reclaim in that window took that for the viewer's pause (the next session then
* attached paused). From ready on, the media's own state is the answer. */
private wantsPlay(media: HTMLMediaElement): boolean {
if (this.pending) return this.pending.play;
if (!this.snapshot.ready && this.attachedIntent) return this.attachedIntent.play;
return !media.paused;
}
/** What the viewer sees: the outgoing media's state, until they have said otherwise. */
shownPlaying(): boolean {
const media = this.deps.media();
const pending = this.pending;
return pending?.said ? pending.play : !!media && !media.paused;
}
/** Switch audio client-side on a multi-rendition session. False when there is none attached yet
* (the caller restarts the session with the new track instead). */
setAudioTrack(track: number): boolean {
if (!this.deps.params().multi || !this.hls) return false;
try {
this.hls.setAudioTrack(track);
} catch {
/* ignore */
}
return true;
}
}
+61
View File
@@ -0,0 +1,61 @@
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.
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
// measurement (see plexStats.ts).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
}),
);
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
type: data.type,
details: data.details,
}),
);
return {
load(url, media) {
hls.loadSource(url);
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
getAudioTrack: () => hls.audioTrack,
setAudioTrack(track) {
hls.audioTrack = track;
},
levelInfo() {
// currentLevel is -1 while auto-ABR hasn't settled; loadLevel then gives the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
return {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
},
};
}
+24 -6
View File
@@ -36,6 +36,7 @@ export type KeyScope =
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
type ShortcutWhen = "episode" | "video";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
allowInInput: true,
},
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
* sheet lives in App and cannot ask, so the surface tells it. */
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode;
const { episode, video } = conditions;
useEffect(() => {
helpConditions.set({ episode });
helpConditions.set({ episode, video });
return () => helpConditions.set({});
}, [episode]);
}, [episode, video]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// The basic keys of a player on the browser's own media element (the library's, a shared link's):
// Space/k play and pause — but Space on a focused button presses the button — the arrows step 5 s
// inside the item, m mutes, f fills the screen with a video (an audio file has no picture). While the
// element itself has the focus, Space and the arrows are its own controls' (they act on them anyway,
// so a second action from here was a double play/pause and a double step).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
const fs = vi.hoisted(() => ({ requestFullscreen: vi.fn(), exitFullscreen: vi.fn() }));
vi.mock("./fullscreen", () => ({
fullscreenElement: () => null,
requestFullscreen: fs.requestFullscreen,
exitFullscreen: fs.exitFullscreen,
}));
import { MEDIA_STEP_S, useMediaKeys } from "./mediaKeys";
afterEach(() => {
cleanup();
vi.clearAllMocks();
document.body.innerHTML = "";
});
function media(tag: "video" | "audio" = "video", duration = 100) {
const el = document.createElement(tag);
document.body.append(el);
let paused = true;
Object.defineProperty(el, "duration", { value: duration, configurable: true });
Object.defineProperty(el, "paused", { get: () => paused, configurable: true });
vi.spyOn(el, "play").mockImplementation(() => {
paused = false;
return Promise.resolve();
});
vi.spyOn(el, "pause").mockImplementation(() => {
paused = true;
});
return el;
}
function press(key: string, target: EventTarget = document.body): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
target.dispatchEvent(e);
return e;
}
describe("useMediaKeys", () => {
it("Space and k play and pause, and take the key from the page", () => {
const el = media();
renderHook(() => useMediaKeys(el));
const e = press(" ");
expect(el.play).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(true);
press("k");
expect(el.pause).toHaveBeenCalledOnce();
});
it("on the focused element, Space and the arrows are left to its own controls; k still is ours", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 50;
const space = press(" ", el);
const arrow = press("ArrowRight", el);
expect(el.play).not.toHaveBeenCalled();
expect(el.currentTime).toBe(50);
expect([space.defaultPrevented, arrow.defaultPrevented]).toEqual([false, false]);
press("k", el);
expect(el.play).toHaveBeenCalledOnce();
});
it("Space on a focused button presses the button instead", () => {
const el = media();
const button = document.createElement("button");
document.body.append(button);
renderHook(() => useMediaKeys(el));
const e = press(" ", button);
expect(el.play).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("the arrows step inside the item", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 2;
press("ArrowLeft");
expect(el.currentTime).toBe(0);
el.currentTime = 50;
press("ArrowRight");
expect(el.currentTime).toBe(50 + MEDIA_STEP_S);
el.currentTime = 98;
press("ArrowRight");
expect(el.currentTime).toBe(100);
});
it("m mutes and unmutes", () => {
const el = media();
renderHook(() => useMediaKeys(el));
press("m");
expect(el.muted).toBe(true);
press("m");
expect(el.muted).toBe(false);
});
it("f fills the screen with a video, and leaves an audio file alone", () => {
const v = media("video");
const first = renderHook(() => useMediaKeys(v));
press("f");
expect(fs.requestFullscreen).toHaveBeenCalledWith(v);
first.unmount();
const a = media("audio");
renderHook(() => useMediaKeys(a));
const e = press("f");
expect(fs.requestFullscreen).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(false);
});
it("stays out of a text field, and goes with the player", () => {
const el = media();
const input = document.createElement("input");
document.body.append(input);
const { unmount } = renderHook(() => useMediaKeys(el));
press(" ", input);
expect(el.play).not.toHaveBeenCalled();
unmount();
press(" ");
expect(el.play).not.toHaveBeenCalled();
});
});
+58
View File
@@ -0,0 +1,58 @@
// The keys of a player built on the browser's own media element — the library's and a shared link's:
// the keymap's `mediaPlayer` rows (play/pause, a 5 s step, fullscreen, mute), bound to the element.
// Its own controls do the rest; Escape stays with the dialog around it.
//
// While the element ITSELF has the focus (a click on its controls puts it there), Space and the
// arrows are its own: the browser's controls act on them whatever a listener does with the keydown,
// so ours as well played and paused on one Space, and stepped twice on one arrow (measured in Chrome).
import { useEffect, useEffectEvent } from "react";
import { dispatch, isActivationTarget, useHelpConditions, useKeyScope } from "./keymap";
import { exitFullscreen, fullscreenElement, requestFullscreen } from "./fullscreen";
export const MEDIA_STEP_S = 5;
/** Bind the `mediaPlayer` keys to `media` while it is mounted. */
export function useMediaKeys(media: HTMLMediaElement | null): void {
useKeyScope("mediaPlayer", media != null);
useHelpConditions({ video: media instanceof HTMLVideoElement });
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (!media) return;
const step = (ev: KeyboardEvent, by: number) => {
if (ev.target === media) return;
ev.preventDefault();
const end = Number.isFinite(media.duration) ? media.duration : Infinity;
media.currentTime = Math.min(end, Math.max(0, media.currentTime + by));
};
const ownKey = (ev: KeyboardEvent) => ev.target === media && ev.key !== "k";
dispatch(e, "mediaPlayer", {
playPause: (ev) => {
// Space on a focused button presses it (the dialog's ✕, a link's Download), as in every
// player here.
if (isActivationTarget(ev) || ownKey(ev)) return;
ev.preventDefault();
if (media.paused) void media.play().catch(() => {});
else media.pause();
},
seekBack: (ev) => step(ev, -MEDIA_STEP_S),
seekFwd: (ev) => step(ev, MEDIA_STEP_S),
fullscreen: (ev) => {
// An audio file has no picture to fill the screen with.
if (!(media instanceof HTMLVideoElement)) return;
ev.preventDefault();
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(media);
},
mute: (ev) => {
ev.preventDefault();
media.muted = !media.muted;
},
});
});
useEffect(() => {
if (!media) return;
const handler = (e: KeyboardEvent) => onKey(e);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [media]);
}
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// The OS's media controls: what is playing (title, line, picture), the buttons driving the player —
// through ITS controls when it has its own clock and seek (the Plex player), else the element's —
// the progress bar anchored to the player's position, and a clean hand-over: unmounting clears only
// a session that is still ours.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import { SESSION_SKIP_S, useMediaSession, type MediaSessionControls } from "./mediaSession";
type Handler = (d: MediaSessionActionDetails) => void;
let handlers: Map<string, Handler | null>;
let session: {
metadata: MediaMetadata | null;
setActionHandler: (a: string, h: Handler | null) => void;
setPositionState: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
handlers = new Map();
session = {
metadata: null,
setActionHandler: (a, h) => void handlers.set(a, h),
setPositionState: vi.fn(),
};
Object.defineProperty(navigator, "mediaSession", { value: session, configurable: true });
vi.stubGlobal(
"MediaMetadata",
class {
constructor(public init: MediaMetadataInit) {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const act = (name: string, d: Partial<MediaSessionActionDetails> = {}) =>
handlers.get(name)?.({ action: name as MediaSessionAction, ...d });
function controls(position = 100, duration = 3000): MediaSessionControls {
return {
play: vi.fn(),
pause: vi.fn(),
seekTo: vi.fn(),
position: () => position,
duration: () => duration,
};
}
describe("useMediaSession", () => {
it("tells the OS what is playing, with the picture as an absolute URL", () => {
const media = document.createElement("video");
renderHook(() =>
useMediaSession(media, { title: "Pilot", artist: "Westworld", artwork: "/api/plex/image/9" }),
);
const init = (session.metadata as unknown as { init: MediaMetadataInit }).init;
expect(init.title).toBe("Pilot");
expect(init.artist).toBe("Westworld");
expect(init.artwork?.[0]?.src).toBe(new URL("/api/plex/image/9", document.baseURI).href);
});
it("drives the player through its own controls, skips clamped to the item", () => {
const media = document.createElement("video");
const c = controls(5, 3000);
renderHook(() => useMediaSession(media, { title: "T" }, c));
act("play");
act("pause");
expect(c.play).toHaveBeenCalledOnce();
expect(c.pause).toHaveBeenCalledOnce();
act("seekbackward");
expect(c.seekTo).toHaveBeenLastCalledWith(0);
act("seekforward", { seekOffset: 30 });
expect(c.seekTo).toHaveBeenLastCalledWith(35);
act("seekto", { seekTime: 1234 });
expect(c.seekTo).toHaveBeenLastCalledWith(1234);
});
it("anchors the OS's progress bar to the player's own position, not the element's clock", () => {
const media = document.createElement("video");
renderHook(() => useMediaSession(media, { title: "T" }, controls(1800, 3000)));
media.dispatchEvent(new Event("seeked"));
expect(session.setPositionState).toHaveBeenLastCalledWith({
duration: 3000,
position: 1800,
playbackRate: 1,
});
});
it("without controls of its own, plays and seeks the element", () => {
const media = document.createElement("video");
const play = vi.spyOn(media, "play").mockResolvedValue();
Object.defineProperty(media, "duration", { value: 600, configurable: true });
renderHook(() => useMediaSession(media, { title: "T" }));
act("play");
expect(play).toHaveBeenCalledOnce();
act("seekforward");
expect(media.currentTime).toBe(SESSION_SKIP_S);
});
it("clears the session on unmount — unless another player has taken it since", () => {
const a = document.createElement("video");
const first = renderHook(() => useMediaSession(a, { title: "A" }));
first.unmount();
expect(session.metadata).toBeNull();
expect(handlers.get("play")).toBeNull();
const one = renderHook(() => useMediaSession(a, { title: "A" }));
const two = renderHook(() => useMediaSession(document.createElement("video"), { title: "B" }));
one.unmount();
expect((session.metadata as unknown as { init: MediaMetadataInit }).init.title).toBe("B");
expect(handlers.get("play")).toBeTypeOf("function");
two.unmount();
});
it("does nothing without a title or an element", () => {
renderHook(() => useMediaSession(null, { title: "T" }));
renderHook(() => useMediaSession(document.createElement("video"), null));
expect(session.metadata).toBeNull();
expect(handlers.size).toBe(0);
});
});
+161
View File
@@ -0,0 +1,161 @@
// What is playing, told to the operating system through the Media Session API: the lock screen, the
// notification shade, a headset's buttons and the keyboard's media keys show the title and the
// picture and control the player — play, pause, skip back and forward, and a seek from the OS's own
// progress bar. One definition for the Plex, library and shared-link players.
//
// The YouTube player is not one of them, by measurement: the browser routes the OS controls to the
// frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title,
// channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown.
//
// It is also what lets a browser's own "background playback" keep the sound going when the page is
// backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element
// for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of
// the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which
// STOPPED playback instead of keeping it alive (measured on iPad Brave).
import { useEffect, useEffectEvent } from "react";
export interface MediaSessionInfo {
title: string;
/** The line under the title: a channel, a show, an uploader. */
artist?: string | null;
/** A picture of it (same-origin or absolute URL). */
artwork?: string | null;
}
/** How the OS's controls drive a player whose playhead is not simply its element's (a Plex
* transcode starts its stream at the resume point, and a seek outside it restarts the stream). */
export interface MediaSessionControls {
play(): void;
pause(): void;
seekTo(seconds: number): void;
/** Where it is, in the item's own seconds. */
position(): number;
/** How long the item is; 0 while unknown. */
duration(): number;
}
/** The OS's skip back / forward step when it does not say one. */
export const SESSION_SKIP_S = 10;
/** The controls of a plain media element: its own clock, its own seek. */
function elementControls(media: HTMLMediaElement): MediaSessionControls {
return {
play: () => void media.play().catch(() => {}),
pause: () => media.pause(),
seekTo: (s) => {
media.currentTime = s;
},
position: () => media.currentTime,
duration: () => (Number.isFinite(media.duration) ? media.duration : 0),
};
}
// The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at
// the stated rate, so nothing is sent per frame.
const POSITION_EVENTS = [
"loadedmetadata",
"durationchange",
"play",
"pause",
"seeked",
"ratechange",
] as const;
const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const;
// The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the
// React Compiler skip it (see CLAUDE.md's async-handler row).
/** The metadata, told to the OS — or null when this browser refuses it (then no session at all,
* rather than half of one). */
function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null {
try {
const metadata = new MediaMetadata(init);
session.metadata = metadata;
return metadata;
} catch {
return null;
}
}
function handle(
session: MediaSession,
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
): void {
try {
session.setActionHandler(action, handler);
} catch {
/* an action this browser does not know */
}
}
function setPosition(session: MediaSession, state?: MediaPositionState): void {
try {
session.setPositionState(state);
} catch {
/* a position the browser refuses (it checks position <= duration itself), or nothing to clear */
}
}
/**
* Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the
* element's own) is what its buttons do. Cleared on unmount, unless another player has already
* claimed the session since.
*/
export function useMediaSession(
media: HTMLMediaElement | null,
info: MediaSessionInfo | null | undefined,
controls?: MediaSessionControls,
): void {
const title = info?.title ?? null;
const artist = info?.artist ?? "";
const artwork = info?.artwork ?? null;
// Read when an action arrives, not bound at the effect: the Plex player's controls are fresh
// closures every render, and re-telling the OS on each render would be churn.
const ctl = useEffectEvent(
(): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null),
);
useEffect(() => {
const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined;
if (!session || !media || title == null || typeof MediaMetadata === "undefined") return;
const metadata = claim(session, {
title,
artist,
artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [],
});
if (!metadata) return;
const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => {
const c = ctl();
if (!c) return;
const end = c.duration() || Infinity;
c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S))));
};
handle(session, "play", () => ctl()?.play());
handle(session, "pause", () => ctl()?.pause());
handle(session, "seekbackward", skip(-1));
handle(session, "seekforward", skip(1));
handle(session, "seekto", (d) => {
if (d.seekTime != null) ctl()?.seekTo(d.seekTime);
});
const anchor = () => {
const c = ctl();
const duration = c?.duration() ?? 0;
if (!c || !(duration > 0)) return;
setPosition(session, {
duration,
position: Math.min(duration, Math.max(0, c.position())),
playbackRate: media.playbackRate || 1,
});
};
for (const type of POSITION_EVENTS) media.addEventListener(type, anchor);
anchor();
return () => {
for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor);
if (session.metadata !== metadata) return; // another player has the session now
session.metadata = null;
for (const action of ACTIONS) handle(session, action, null);
setPosition(session);
};
}, [media, title, artist, artwork]);
}
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { episodeLine } from "./plexDetail";
describe("episodeLine", () => {
it("names the episode by its season and number", () => {
expect(episodeLine({ season_number: 2, episode_number: 7, title: "Pilot" })).toBe(
"S2·E7 — Pilot",
);
});
it("is the title alone for an episode Plex has no number for", () => {
expect(episodeLine({ season_number: 1, episode_number: null, title: "Special" })).toBe(
"Special",
);
expect(episodeLine({ season_number: null, episode_number: 3, title: "Special" })).toBe(
"Special",
);
});
});
+25 -3
View File
@@ -205,9 +205,31 @@ export function useCrossfadeBackdrop(
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
export function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
const n = episodeNumbers(ep);
return n ? `S${n.season} · E${n.episode}` : ep.title;
}
type Numbered = { season_number?: number | null; episode_number?: number | null };
/** An episode's season and episode number, or null when Plex has no number for one of them (a
* special). Every place that names an episode asks this, so a numberless one is named by its title
* alone everywhere: a template literal printed the missing number as "Enull", JSX as "E", and a
* `?? "?"` as "E?", which told the viewer nothing. */
export function episodeNumbers(ep: Numbered): { season: number; episode: number } | null {
if (ep.season_number == null || ep.episode_number == null) return null;
return { season: ep.season_number, episode: ep.episode_number };
}
/** An episode named in a line of its own ("Next: …", "Mark … watched"): "S1·E4", or its title. */
export function episodeName(ep: Numbered & { title: string }): string {
const n = episodeNumbers(ep);
return n ? `S${n.season}·E${n.episode}` : ep.title;
}
/** An episode's line under its show's name: "S1·E4 — Title", or the title alone (see above). */
export function episodeLine(ep: Numbered & { title: string }): string {
const n = episodeNumbers(ep);
return n ? `S${n.season}·E${n.episode} — ${ep.title}` : ep.title;
}
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
+47
View File
@@ -0,0 +1,47 @@
// Which credits block ends an item, and what skipping each marker does. An item can carry several
// credits blocks — mid- and post-credit scenes play between them — and only the last one may move
// on to the next item; only the one Plex itself flags as the end may do so on its own.
import { describe, it, expect } from "vitest";
import type { PlexMarker } from "./api";
import { markerSkip } from "./plexMarkers";
const credits = (start_s: number, end_s: number, final = false): PlexMarker => ({
type: "credits",
start_s,
end_s,
final,
});
const intro: PlexMarker = { type: "intro", start_s: 10, end_s: 60, final: false };
describe("markerSkip", () => {
it("an intro jumps to its end, on its own when the viewer wants that", () => {
expect(markerSkip(intro, [intro])).toEqual({ leaves: false, auto: true });
});
it("only the credits Plex flags final leave the item, and only they may do it on their own", () => {
const first = credits(6811, 6831);
const middle = credits(7039, 7507);
const end = credits(7549, 7559, true);
const all = [intro, first, middle, end];
expect(markerSkip(first, all)).toEqual({ leaves: false, auto: false });
expect(markerSkip(middle, all)).toEqual({ leaves: false, auto: false });
expect(markerSkip(end, all)).toEqual({ leaves: true, auto: true });
});
it("with no credits flagged final the last one is the end, but leaves only when asked", () => {
const first = credits(2000, 2100);
const last = credits(2900, 2995);
// listed out of order on purpose: "last" is by time, not by position in the list
expect(markerSkip(last, [last, first])).toEqual({ leaves: true, auto: false });
expect(markerSkip(first, [last, first])).toEqual({ leaves: false, auto: false });
expect(markerSkip(last, [last])).toEqual({ leaves: true, auto: false });
});
it("a final flag on an earlier block wins over a later block", () => {
const end = credits(2000, 2100, true);
const later = credits(2900, 2995);
expect(markerSkip(end, [end, later])).toEqual({ leaves: true, auto: true });
expect(markerSkip(later, [end, later])).toEqual({ leaves: false, auto: false });
});
});
+33
View File
@@ -0,0 +1,33 @@
// What skipping an intro/credits marker does. An item can carry several credits blocks — mid- and
// post-credit scenes play between them — so only the block that ends the item moves on to the next
// one: the block Plex flags `final`, else the last one. Every other credits block is skipped like an
// intro, to its own end. And only an end Plex itself vouches for is left on its own (the credits
// auto-skip); an end inferred from the list waits for the viewer's Skip — the stretch after it may
// still be the show.
import type { PlexMarker } from "./api";
/** `leaves`: skipping it moves on to the next item (else: jumps to its end). `auto`: the auto-skip
* setting may act on it without the viewer. */
export interface MarkerSkip {
leaves: boolean;
auto: boolean;
}
function endCredits(markers: readonly PlexMarker[]): PlexMarker | undefined {
const credits = markers.filter((m) => m.type === "credits");
return (
credits.find((m) => m.final) ??
credits.reduce<PlexMarker | undefined>(
(last, m) => (last && last.start_s > m.start_s ? last : m),
undefined,
)
);
}
export function markerSkip(marker: PlexMarker, markers: readonly PlexMarker[]): MarkerSkip {
if (marker.type === "intro") return { leaves: false, auto: true };
// By offset, not identity: a refetched list hands back equal-but-new objects.
const end = endCredits(markers);
const leaves = end != null && end.start_s === marker.start_s && end.end_s === marker.end_s;
return { leaves, auto: leaves && marker.final };
}
@@ -16,7 +16,6 @@ import { createRef, type ReactNode, type RefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog";
import type Hls from "hls.js";
const RK = "98614";
@@ -44,15 +43,13 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
sessionK,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
playhead: () => (abs > 0 ? abs : loadStart),
}),
{ wrapper, initialProps: { id: RK } },
);
@@ -320,15 +317,13 @@ function TrackHarness() {
sessionK: 0,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: 3600 },
loadStartRef: { current: 0 },
playhead: () => 3600,
});
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
harness.current = tracks;
@@ -407,3 +402,40 @@ describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
expect(harness.current!.subNotice).toBeNull();
});
});
describe("usePlexTracks — the debounced A/V-offset restart", () => {
afterEach(() => vi.useRealTimers());
it("is dropped when the item changes before it fires — it would restart the previous item", () => {
vi.useFakeTimers();
const restart = vi.fn();
const qc = client();
const { result, rerender } = renderHook(
({ id }: { id: string }) =>
usePlexTracks({
id,
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
restartSession: (at) => restart(id, at),
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 600,
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
initialProps: { id: RK },
},
);
act(() => result.current.changeAudioOffset(0.2));
rerender({ id: "next-episode" });
act(() => void vi.advanceTimersByTime(1000));
expect(restart).not.toHaveBeenCalled();
});
});
+44 -45
View File
@@ -7,7 +7,6 @@ import {
type WheelEvent as ReactWheelEvent,
} from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
fullscreenElement,
@@ -18,6 +17,7 @@ import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { useLatest } from "./useLatest";
import i18n, { plexLang } from "../i18n";
import { markerSkip } from "./plexMarkers";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -343,15 +343,6 @@ export function usePlexProgress(opts: {
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that. */
export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
// default volume after a load/seek-restart). The 0..1 arithmetic is the shared, unit-tested core in
@@ -421,9 +412,10 @@ export function usePlayerVolume(opts: {
}
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
// marker end, credits → next item (binge). The returned refs (skipProgressRef / cancelAutoSkipRef)
// are the forward-declaration bridge the earlier-defined keyboard/back handlers read to cancel a
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the
// auto-skip may act on alone, is `lib/plexMarkers`. The returned refs (skipProgressRef /
// cancelAutoSkipRef) are the forward-declaration bridge the earlier-defined keyboard/back handlers read to cancel a
// running countdown (Esc/Backspace during the countdown cancels it and must NOT navigate back).
export function useAutoSkip(opts: {
id: string;
@@ -463,6 +455,10 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
const markersRef = useLatest(markers);
const [skipProgress, setSkipProgress] = useState<number | null>(null);
const skipProgressRef = useLatest(skipProgress);
@@ -480,7 +476,7 @@ export function useAutoSkip(opts: {
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (m.type === "credits" && nextId) {
if (nextId && markerSkip(m, markersRef.current ?? []).leaves) {
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change). The
// write is handed to go() so a refusal takes the finish back: this path has no near-end
@@ -491,7 +487,7 @@ export function useAutoSkip(opts: {
go(nextId, watchedWrite);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo, reportWatchedErrorRef],
[id, go, nextId, seekTo, markersRef, reportWatchedErrorRef],
);
const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current;
@@ -513,7 +509,7 @@ export function useAutoSkip(opts: {
const m = activeMarkerRef.current;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits;
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
@@ -545,14 +541,15 @@ export function useAutoSkip(opts: {
doSkip,
videoRef,
activeMarkerRef,
activeAuto,
]);
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
}
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state and the
// audio/subtitle change/cycle handlers; audio switches re-map server-side via loadSession (single
// stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// audio/subtitle change/cycle handlers; audio switches re-map server-side via a session restart
// (single stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
// component and passed in so this hook and the media session read/write the same substrate.
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
@@ -589,17 +586,17 @@ export function usePlexTracks(opts: {
sessionK: number;
subOffset: number;
subPos: number;
loadSession: (startAt: number, resumePlay?: boolean) => Promise<void>;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
setAudioTrack: (track: number) => boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>;
audioRef: RefObject<number | null>;
aoffRef: RefObject<number>;
absRef: RefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: RefObject<number>;
/** Where the viewer is — before playback has ticked, where the session was ASKED to start
* (HlsEngine.playhead). Anything that restarts "here", and the subtitle poll, must use it. */
playhead: () => number;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -636,8 +633,9 @@ export function usePlexTracks(opts: {
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = opts;
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
@@ -658,7 +656,7 @@ export function usePlexTracks(opts: {
}, [subOffset]);
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so loadSession sees it.
// position (like a seek). The ref is updated synchronously so the session request sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
@@ -668,20 +666,11 @@ export function usePlexTracks(opts: {
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
const track = ord ?? 0;
try {
hlsRef.current.audioTrack = track;
} catch {
/* ignore */
}
} else {
// Single audio (or the session has not answered yet): re-map the track server-side.
void loadSession(playhead(absRef, loadStartRef));
}
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
// Single audio (or the session has not answered yet): re-map the track server-side.
if (!setAudioTrack(ord ?? 0)) restartSession(playhead());
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
[restartSession, setAudioTrack, detail, patchPrefs, audioRef, playhead],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
@@ -730,9 +719,19 @@ export function usePlexTracks(opts: {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
aoffTimer.current = window.setTimeout(() => restartSession(playhead()), 500);
},
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
[patchPrefs, restartSession, aoffRef, playhead],
);
// A restart still waiting belongs to the item it was asked on: fired after a move to the next one,
// it restarted the PREVIOUS item, whose stream then played under the new title. The new item's
// session reads the offset itself (`aoffRef`), so nothing is lost by dropping it.
useEffect(
() => () => {
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = null;
},
[id],
);
// --- is the selected track actually extractable yet? -------------------------------------------
@@ -751,7 +750,7 @@ export function usePlexTracks(opts: {
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead()),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
@@ -852,7 +851,7 @@ export function usePlexTracks(opts: {
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
// sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
+1 -1
View File
@@ -1,6 +1,6 @@
// Pure formatters + row builder for the Plex player "stats for nerds" overlay. Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// the session engine and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
// The numbers are in the UI language ("4,2 Mbps", "12,3 mp" in Hungarian); the unit symbols of a
// stream (Mbps, kbps) are the same in both.
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
// One rule, two languages, no shared wire format — so a test is the only thing that holds them
// together. Which point a library item keeps is the server's (`routes/_common.kept_position`); the
// client repeats it (`lib/resume.ts`) for the shared link, which has no server side, and for the
// library row it patches before the server has answered. A server-only change left the row showing
// a Continue point the server had already cleared, and a link resuming at points the library no
// longer keeps. Both must be EQUAL: neither direction is merely conservative here.
//
// Read from source rather than imported, as in plexFinishMargin.contract.test.ts.
function constantIn(path: string, pattern: RegExp): number {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
const m = pattern.exec(source);
// If this fails the constant was renamed or moved — find it and re-point this test, because the
// rule it guards did not go away.
expect(m, `${pattern} not found in ${path}`).not.toBeNull();
return Number(m![1]);
}
const SERVER = "../../../backend/app/routes/_common.py";
it.each([
["the least position that counts as started", "PROGRESS_MIN_SECONDS", "PROGRESS_MIN_S"],
["the margin before the end that counts as finished", "FINISH_MARGIN_SECONDS", "FINISH_MARGIN_S"],
])("keeps %s the same as the server", (_what, server, client) => {
const backend = constantIn(SERVER, new RegExp(`^${server}\\s*=\\s*(\\d+)`, "m"));
const frontend = constantIn("./resume.ts", new RegExp(`^const ${client}\\s*=\\s*(\\d+)`, "m"));
expect(backend).toBeGreaterThan(0);
expect(frontend).toBe(backend);
});
+205
View File
@@ -0,0 +1,205 @@
// @vitest-environment jsdom
//
// The resume of a player on the browser's own media element: it opens at the kept point, reports
// where it stops (a pause, the periodic checkpoint, the end, the page going away, the player
// closing), and reports NOTHING before the item played — a player closed at once must not overwrite
// the kept point with the 0:00 it never left. A shared link's point lives in this browser.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import {
CHECKPOINT_S,
LINK_POINTS_MAX,
keptPosition,
linkPoint,
saveLinkPoint,
useResume,
} from "./resume";
import { LS } from "./storage";
/** A media element whose clock and length the test sets (jsdom plays nothing). */
function fakeMedia(duration = 600) {
const el = document.createElement("video");
let t = 0;
let paused = true;
let ready = 0;
Object.defineProperties(el, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => (ready ? duration : NaN), configurable: true },
readyState: { get: () => ready, configurable: true },
paused: { get: () => paused, configurable: true },
ended: { get: () => ready > 0 && t >= duration, configurable: true },
});
return {
el,
metadata() {
ready = 1;
el.dispatchEvent(new Event("loadedmetadata"));
},
play() {
paused = false;
el.dispatchEvent(new Event("playing"));
},
at(s: number) {
t = s;
el.dispatchEvent(new Event("timeupdate"));
},
pause() {
paused = true;
el.dispatchEvent(new Event("pause"));
},
};
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("useResume", () => {
it("opens at the kept point once the element knows its length", () => {
const m = fakeMedia();
renderHook(() => useResume(m.el, "7", 300, () => {}));
expect(m.el.currentTime).toBe(0);
m.metadata();
expect(m.el.currentTime).toBe(300);
});
it("drops a kept point past the end rather than clamping onto it", () => {
const m = fakeMedia(200);
renderHook(() => useResume(m.el, "7", 300, () => {}));
m.metadata();
expect(m.el.currentTime).toBe(0);
});
it("reports nothing when the player closes before the item played", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 300, save));
m.metadata();
unmount();
expect(save).not.toHaveBeenCalled();
});
it("reports a pause, and the close as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(42);
m.pause();
expect(save).toHaveBeenLastCalledWith(42, 600, false);
unmount();
expect(save).toHaveBeenLastCalledWith(42, 600, true);
});
it("counts an element already playing when it subscribes as played", () => {
// A cached file started from 0 autoplays before the effect runs: its one `playing` is gone.
const m = fakeMedia();
m.metadata();
m.play();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.at(42);
m.pause();
expect(save).toHaveBeenLastCalledWith(42, 600, false);
});
it("checkpoints while it plays, at most once per interval", () => {
vi.useFakeTimers();
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(1);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(CHECKPOINT_S * 1000 - 1);
m.at(10);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
m.at(11);
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith(11, 600, false);
});
it("reports the end as the full length (finished)", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(600);
m.el.dispatchEvent(new Event("ended"));
expect(save).toHaveBeenLastCalledWith(600, 600, false);
});
it("reports the page going away as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(30);
window.dispatchEvent(new Event("pagehide"));
expect(save).toHaveBeenLastCalledWith(30, 600, true);
});
});
describe("keptPosition (the server's rule)", () => {
it("keeps a point in the middle, clears one too early or too near the end", () => {
expect(keptPosition(754.9, 3000)).toBe(754);
expect(keptPosition(4, 3000)).toBe(0);
expect(keptPosition(2995, 3000)).toBe(0);
expect(keptPosition(120, 0)).toBe(120); // an unknown length cannot be "near the end"
});
// The edges themselves, one second either side: a `>=` that became `>` on one side only moves a
// rule both sides must apply alike, and the constants alone (resume.contract.test.ts) would not see
// it. backend/tests/test_library_progress.py holds the server to the same rows.
it.each([
[4, 3000, 0],
[5, 3000, 5],
[2990, 3000, 2990],
[2991, 3000, 0],
])("at %s s of %s keeps %s", (position, duration, kept) => {
expect(keptPosition(position, duration)).toBe(kept);
});
});
describe("a shared link's point", () => {
beforeEach(() => localStorage.clear());
it("is kept per link, and forgotten at a point not worth keeping", () => {
saveLinkPoint("tok-a", 125, 600);
saveLinkPoint("tok-b", 300, 600);
expect(linkPoint("tok-a")).toBe(125);
expect(linkPoint("tok-b")).toBe(300);
saveLinkPoint("tok-a", 598, 600);
expect(linkPoint("tok-a")).toBe(0);
expect(linkPoint("tok-b")).toBe(300);
});
it("keeps only the most recent links", () => {
vi.useFakeTimers();
for (let i = 0; i <= LINK_POINTS_MAX; i++) {
vi.advanceTimersByTime(1000);
saveLinkPoint(`tok-${i}`, 100, 600);
}
expect(linkPoint("tok-0")).toBe(0);
expect(linkPoint(`tok-${LINK_POINTS_MAX}`)).toBe(100);
expect(Object.keys(JSON.parse(localStorage.getItem(LS.watchResume)!))).toHaveLength(
LINK_POINTS_MAX,
);
});
it("reads a broken stored value as from the start", () => {
localStorage.setItem(LS.watchResume, JSON.stringify({ "tok-a": "nonsense", x: [1] }));
expect(linkPoint("tok-a")).toBe(0);
localStorage.setItem(LS.watchResume, "[1,2]");
expect(linkPoint("tok-a")).toBe(0);
saveLinkPoint("tok-a", 100, 600);
expect(linkPoint("tok-a")).toBe(100);
});
});
+126
View File
@@ -0,0 +1,126 @@
// Where a player built on the browser's own media element (the library's, a shared link's) stopped,
// and opening it there again. The Plex and YouTube players keep their resume in their own session
// code (a Plex session starts AT the point, a YouTube player is created with it); this is the same
// promise for the two that play a plain file.
//
// Where the point is kept is the caller's: the library's on the server (`library_states`), a shared
// link's in this browser (the viewer has no account). The rule for which point is worth keeping is
// the server's own (`routes/_common.kept_position`), repeated here for the link that has no server
// side (`resume.contract.test.ts` holds the two numbers equal): too early to count as started, or
// close enough to the end to count as finished, is "from the start".
import { useEffect, useEffectEvent } from "react";
import { LS, readJSON, writeJSON } from "./storage";
const PROGRESS_MIN_S = 5;
const FINISH_MARGIN_S = 10;
/** How often a playing item checkpoints its point. */
export const CHECKPOINT_S = 10;
/** The whole-second point worth keeping, or 0 for "from the start" (the server's rule). */
export function keptPosition(position: number, duration: number): number {
const at = Math.max(0, Math.floor(position));
const nearEnd = duration > 0 && at > Math.floor(duration) - FINISH_MARGIN_S;
return at >= PROGRESS_MIN_S && !nearEnd ? at : 0;
}
// A point inside the item, applied once the element knows its length. A start the element cannot
// honour (past its end: a shorter file under the same entry) is dropped, not clamped.
function openAt(media: HTMLMediaElement, start: number): void {
if (start > 0 && Number.isFinite(media.duration) && start < media.duration) {
media.currentTime = start;
}
}
/**
* Open `media` at `start` (once per `itemKey`) and report where it stops through `save`: every
* CHECKPOINT_S while it plays, on a pause, at the end (which reads as finished), and with
* `leaving` when the page goes away or the player closes — the moment a normal request may not
* finish. Nothing is reported before the item actually played: a player closed at once, or the
* development double mount, must not overwrite the kept point with the 0:00 it never left.
*/
export function useResume(
media: HTMLMediaElement | null,
itemKey: string | null,
start: number,
save: (position: number, duration: number, leaving: boolean) => void,
): void {
const report = useEffectEvent(save);
useEffect(() => {
if (!media || itemKey == null) return;
// Already playing counts as played: a cached file that autoplays from the start can fire its
// one `playing` before this effect subscribes, and would then never report at all.
let played = !media.paused;
let lastAt = 0;
const duration = () => (Number.isFinite(media.duration) ? media.duration : 0);
const put = (leaving: boolean) => {
if (!played) return;
lastAt = Date.now();
report(media.ended ? duration() : media.currentTime, duration(), leaving);
};
const restore = () => openAt(media, start);
if (media.readyState >= HTMLMediaElement.HAVE_METADATA) restore();
else media.addEventListener("loadedmetadata", restore, { once: true });
const onPlaying = () => {
played = true;
};
const onTime = () => {
if (played && !media.paused && Date.now() - lastAt >= CHECKPOINT_S * 1000) put(false);
};
const onStop = () => put(false);
const onHide = () => put(true);
media.addEventListener("playing", onPlaying);
media.addEventListener("timeupdate", onTime);
media.addEventListener("pause", onStop);
media.addEventListener("ended", onStop);
window.addEventListener("pagehide", onHide);
return () => {
media.removeEventListener("loadedmetadata", restore);
media.removeEventListener("playing", onPlaying);
media.removeEventListener("timeupdate", onTime);
media.removeEventListener("pause", onStop);
media.removeEventListener("ended", onStop);
window.removeEventListener("pagehide", onHide);
put(true);
};
}, [media, itemKey, start]);
}
// --- a shared link's point, kept in this browser ---------------------------------------------------
type LinkPoints = Record<string, { at: number; t: number }>;
/** The most links whose point this browser keeps — the oldest go first. */
export const LINK_POINTS_MAX = 50;
// Whatever the storage holds is read as untrusted: another version of the page, or a hand edit, may
// have left anything there, and a broken entry must read as "from the start", not throw.
function readPoints(): LinkPoints {
const raw = readJSON<unknown>(LS.watchResume, {});
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: LinkPoints = {};
for (const [token, v] of Object.entries(raw as Record<string, unknown>)) {
const p = v as { at?: unknown; t?: unknown } | null;
if (typeof p?.at === "number" && p.at > 0 && typeof p.t === "number") {
out[token] = { at: p.at, t: p.t };
}
}
return out;
}
/** Where this browser stopped in the shared link `token` (0 = from the start). */
export function linkPoint(token: string): number {
return readPoints()[token]?.at ?? 0;
}
/** Keep (or, at a point not worth keeping, forget) where this browser stopped in `token`. */
export function saveLinkPoint(token: string, position: number, duration: number): void {
const points = readPoints();
const at = keptPosition(position, duration);
if (at) points[token] = { at, t: Date.now() };
else delete points[token];
const kept = Object.entries(points)
.sort(([, a], [, b]) => b.t - a.t)
.slice(0, LINK_POINTS_MAX);
writeJSON(LS.watchResume, Object.fromEntries(kept));
}
+3
View File
@@ -74,6 +74,9 @@ export const LS = {
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
// reason as above: the viewer has no account to keep it in.
watchResume: "siftlode.watchResume",
} as const;
// --- per-account scoping -----------------------------------------------------------------
+16 -2
View File
@@ -1,4 +1,5 @@
import { defineConfig, type Plugin } from "vite";
import { ServerResponse } from "node:http";
import { defineConfig, type Plugin, type ProxyOptions } from "vite";
import { reactWithCompiler } from "./reactPlugin";
import { devOnlyGuard } from "./devOnlyGuard";
@@ -10,7 +11,20 @@ const target = "http://127.0.0.1:8080";
// (the TCP target is still 127.0.0.1:8080 — changeOrigin only rewrites the header). The backend uses
// that header to send the OAuth redirect_uri back to :5173, so a Google login started on the Vite dev
// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.)
const proxyOpts = { target, changeOrigin: false };
//
// A failed connect to the container (on Windows the Docker port publish now and then times one out)
// is a gateway failure, so it answers 502 like the prod reverse proxy: the app reads that as a
// passing connection loss (a quiet notice; a GET is retried once). Vite's own handler, which runs
// after this one and leaves an answered response alone, sends a bodiless 500 — read as a real
// server fault, the blocking error dialog.
const proxyOpts: ProxyOptions = {
target,
changeOrigin: false,
configure: (proxy) =>
proxy.on("error", (_err, _req, res) => {
if (res instanceof ServerResponse && !res.headersSent) res.writeHead(502).end();
}),
};
const proxy = {
"/api": proxyOpts,
"/auth": proxyOpts,