diff --git a/CLAUDE.md b/CLAUDE.md index b5d30889..ae8fa045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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])` | diff --git a/README.md b/README.md index 1869a810..80ab33b7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 9ffc4757..1987e095 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -743,14 +743,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 47, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/backend/alembic/versions/0099_library_states.py b/backend/alembic/versions/0099_library_states.py new file mode 100644 index 00000000..9781b912 --- /dev/null +++ b/backend/alembic/versions/0099_library_states.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 4c799a33..163026d4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index 9776427a..d70a12aa 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -9,16 +9,51 @@ 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. 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 diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index a6508e4b..6dcc0784 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 031e2870..15da2ecb 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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: diff --git a/backend/tests/test_library_progress.py b/backend/tests/test_library_progress.py new file mode 100644 index 00000000..aa1517ce --- /dev/null +++ b/backend/tests/test_library_progress.py @@ -0,0 +1,121 @@ +"""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). +""" + +from sqlalchemy import select + +from app.models import DownloadJob, DownloadShare, LibraryState + + +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 + + +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) == [] diff --git a/backend/tests/test_progress_bounds.py b/backend/tests/test_progress_bounds.py index 7736cb7c..d2d0daf6 100644 --- a/backend/tests/test_progress_bounds.py +++ b/backend/tests/test_progress_bounds.py @@ -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.""" diff --git a/backend/tests/test_request_models.py b/backend/tests/test_request_models.py index 5b5d48b0..ee59168e 100644 --- a/backend/tests/test_request_models.py +++ b/backend/tests/test_request_models.py @@ -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". --- diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 544589fb..4a05fd31 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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) | diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index f3e19550..59b762d8 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -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(".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); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index aa6c5fca..a6f0bea4 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -68,7 +68,9 @@ export default function DownloadCenter() { const [manage, setManage] = useState(false); const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); - const [playJob, setPlayJob] = useState(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(null); const [errorJob, setErrorJob] = useState(null); @@ -276,7 +278,9 @@ export default function DownloadCenter() { setPlayJob(job)} - title={t("downloads.actions.play")} + title={ + job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") + } > @@ -315,7 +319,9 @@ export default function DownloadCenter() { setPlayJob(job)} - title={t("downloads.actions.play")} + title={ + job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") + } > @@ -357,7 +363,13 @@ export default function DownloadCenter() { {manage && setManage(false)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} - {playJob && setPlayJob(null)} />} + {playing && ( + setPlaying(null)} + /> + )} {editJob && setEditJob(null)} />} {errorJob && ( . 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(); @@ -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>): 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({ui}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** The rendered
+
+ {src ? ( { const actual = await importOriginal(); @@ -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(null); + useMediaKeys(media); + return tag === "video" ? 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 diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 8b825be1..90f4767c 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -36,6 +36,7 @@ export type KeyScope = | "contextMenu" | "ytPlayer" | "plexPlayer" + | "mediaPlayer" | "plexBrowse"; const PAGE_LEVEL: ReadonlySet = new Set([ @@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet = new Set([ 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({}); -/** 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 ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/mediaKeys.dom.test.ts b/frontend/src/lib/mediaKeys.dom.test.ts new file mode 100644 index 00000000..5713381d --- /dev/null +++ b/frontend/src/lib/mediaKeys.dom.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/mediaKeys.ts b/frontend/src/lib/mediaKeys.ts new file mode 100644 index 00000000..6667552f --- /dev/null +++ b/frontend/src/lib/mediaKeys.ts @@ -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]); +} diff --git a/frontend/src/lib/mediaSession.dom.test.ts b/frontend/src/lib/mediaSession.dom.test.ts new file mode 100644 index 00000000..612cbcd8 --- /dev/null +++ b/frontend/src/lib/mediaSession.dom.test.ts @@ -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; +let session: { + metadata: MediaMetadata | null; + setActionHandler: (a: string, h: Handler | null) => void; + setPositionState: ReturnType; +}; + +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 = {}) => + 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); + }); +}); diff --git a/frontend/src/lib/mediaSession.ts b/frontend/src/lib/mediaSession.ts new file mode 100644 index 00000000..cde886c2 --- /dev/null +++ b/frontend/src/lib/mediaSession.ts @@ -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]); +} diff --git a/frontend/src/lib/resume.dom.test.ts b/frontend/src/lib/resume.dom.test.ts new file mode 100644 index 00000000..29053ec7 --- /dev/null +++ b/frontend/src/lib/resume.dom.test.ts @@ -0,0 +1,181 @@ +// @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("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" + }); +}); + +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); + }); +}); diff --git a/frontend/src/lib/resume.ts b/frontend/src/lib/resume.ts new file mode 100644 index 00000000..396d4d44 --- /dev/null +++ b/frontend/src/lib/resume.ts @@ -0,0 +1,124 @@ +// 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: 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; + let played = false; + 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; +/** 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(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)) { + 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)); +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 10ab9510..ce71a7d7 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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 -----------------------------------------------------------------