feat(player): the OS media controls, the basic keys and resume for the library and shared-link players

Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

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

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
This commit is contained in:
2026-10-06 23:55:41 +02:00
parent 02feaa9a1c
commit 59b27467ab
32 changed files with 1489 additions and 133 deletions
+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 static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached | | FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded | | FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first | | FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` | | FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
+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. - **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
- **In-app player** with resume, plus keyboard/scroll controls. - **In-app player** with resume, plus keyboard/scroll controls.
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format - **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
to your device, share with another user, or hand out a public watch link. where you left off, then save to your device, share with another user, or hand out a public watch
link.
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its - **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
movies and shows alongside your feed, with watch state kept in sync. movies and shows alongside your feed, with watch state kept in sync.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
-8
View File
@@ -743,14 +743,6 @@
"lineCount": 1 "lineCount": 1
} }
}, },
{
"code": "reportArgumentType",
"range": {
"startColumn": 37,
"endColumn": 47,
"lineCount": 1
}
},
{ {
"code": "reportArgumentType", "code": "reportArgumentType",
"range": { "range": {
@@ -0,0 +1,42 @@
"""library: a resume point per user and library item
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
status: the library has no "watched". It starts empty; the downgrade drops it.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0099_library_states"
down_revision: str | None = "0098_drop_demo_account"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"library_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"job_id",
sa.Integer(),
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position_seconds", sa.Integer(), nullable=False),
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
)
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
def downgrade() -> None:
op.drop_index("ix_library_states_job_id", table_name="library_states")
op.drop_table("library_states")
+21
View File
@@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LibraryState(Base):
"""Where a user stopped in a library item (their own download, or one shared with them) — the
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
__tablename__ = "library_states"
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
job_id: Mapped[int] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class SearchFind(Base, TimestampMixin): class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search. """Per-user record of a video the user surfaced via their own live YouTube search.
+36 -1
View File
@@ -9,16 +9,51 @@ one existence-leak policy.
from typing import IO, Any, TypeVar from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import Base from app.db import Base
from app.models import User from app.models import POSITION_MAX_S, User
from app.issues import gitea from app.issues import gitea
from app.refusal import refused from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base) M = TypeVar("M", bound=Base)
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
# library's): positions below this are "didn't really start" and within this of the end are
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers. NB: the Plex player
# intentionally uses a LARGER finish margin (30s) because it marks long-form items watched, not just
# clears a resume bar — see routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
class ProgressIn(BaseModel):
"""A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the
page goes away)."""
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (`kept_position`) — the player may post a raw currentTime (a float),
# which the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on
# the progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
def kept_position(body: ProgressIn) -> int:
"""The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to
count as started, or close enough to the end to count as finished)."""
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0
def not_found(what: str = "generic") -> HTTPException: def not_found(what: str = "generic") -> HTTPException:
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's """The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
+68 -4
View File
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
import os import os
import re import re
import tempfile import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -17,7 +17,8 @@ from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.mailtext import lang_for_user from app.mailtext import lang_for_user
@@ -39,11 +40,19 @@ from app.models import (
DownloadProfile, DownloadProfile,
DownloadQuota, DownloadQuota,
DownloadShare, DownloadShare,
LibraryState,
MediaAsset, MediaAsset,
User, User,
display_text, display_text,
) )
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404 from app.routes._common import (
ProgressIn,
capped_stream_to,
get_or_404,
kept_position,
not_found,
owned_or_404,
)
from app.security import hash_password from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso from app.utils import iso
@@ -165,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows} return {a.id: a for a in rows}
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
ids = [j.id for j in jobs]
if not ids:
return {}
rows = db.execute(
select(LibraryState.job_id, LibraryState.position_seconds).where(
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
)
).all()
return {job_id: pos for job_id, pos in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict: def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step """Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares.""" every single-job handler shares."""
@@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
.all() .all()
) )
assets = _assets_for(db, jobs) assets = _assets_for(db, jobs)
return [_serialize(j, assets.get(j.asset_id)) for j in jobs] positions = _positions_for(db, user, jobs)
return [
{
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0),
}
for j in jobs
]
@router.get("/usage") @router.get("/usage")
@@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
.all() .all()
) )
assets = _assets_for(db, jobs) assets = _assets_for(db, jobs)
positions = _positions_for(db, user, jobs)
out = [] out = []
for j in jobs: for j in jobs:
data = _serialize(j, assets.get(j.asset_id)) data = _serialize(j, assets.get(j.asset_id))
data["shared"] = True data["shared"] = True
data["position_seconds"] = positions.get(j.id, 0)
out.append(data) out.append(data)
return out return out
@@ -825,6 +856,39 @@ def download_file(
# --- editor filmstrip (scrub track) -------------------------------------------------------- # --- editor filmstrip (scrub track) --------------------------------------------------------
@router.post("/{job_id}/progress")
def set_library_progress(
job_id: int,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
one shared with them). A position too early or too close to the end clears it instead."""
_accessible_job(db, user, job_id)
position = kept_position(body)
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
if not position:
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
# one stop may both clear it, and the second then deletes nothing rather than failing.
db.execute(delete(LibraryState).where(mine))
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": 0}
now = datetime.now(timezone.utc)
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
# read-then-insert that the second would fail on the unique key.
db.execute(
pg_insert(LibraryState)
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
.on_conflict_do_update(
constraint="uq_library_user_job",
set_={"position_seconds": position, "progress_updated_at": now},
)
)
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": position}
@router.get("/{job_id}/storyboard") @router.get("/{job_id}/storyboard")
def storyboard_meta( def storyboard_meta(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+11 -34
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,14 +26,19 @@ from app.feedquery import (
windowed_relevance_rows, windowed_relevance_rows,
) )
from app.models import ( from app.models import (
POSITION_MAX_S,
ChannelTag, ChannelTag,
Tag, Tag,
User, User,
Video, Video,
VideoState, VideoState,
) )
from app.routes._common import get_or_404, youtube_refusal, not_found from app.routes._common import (
ProgressIn,
get_or_404,
kept_position,
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration from app.sync.videos import parse_iso8601_duration
from app.utils import iso from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist. # "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"} VALID_STATES = {"new", "watched", "hidden"}
# Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth
# storing, so we clear the position instead (the video shows no progress bar).
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count. # Shared query parameters for /feed and /feed/count.
def _feed_params( def _feed_params(
@@ -326,23 +323,10 @@ def clear_video_state(
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0} return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
class VideoProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
position_seconds: float = Field(0, le=POSITION_MAX_S)
duration_seconds: float = Field(0, le=POSITION_MAX_S)
@router.post("/videos/{video_id}/progress") @router.post("/videos/{video_id}/progress")
def set_video_progress( def set_video_progress(
video_id: str, video_id: str,
body: VideoProgressIn, body: ProgressIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -350,22 +334,15 @@ def set_video_progress(
Stores a per-user position on the video_states row without touching watch status, so a Stores a per-user position on the video_states row without touching watch status, so a
partially-watched video can render a progress bar and match the "in progress" filter. partially-watched video can render a progress bar and match the "in progress" filter.
Trivially-early and near-finished positions clear the position rather than store it.""" Trivially-early and near-finished positions clear the position rather than store it."""
position = int(body.position_seconds) position = kept_position(body)
duration = int(body.duration_seconds)
if position < 0:
position = 0
get_or_404(db, Video, video_id, what="video") get_or_404(db, Video, video_id, what="video")
# Decide whether this position is worth keeping (else clear it).
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
keep = position >= PROGRESS_MIN_SECONDS and not near_end
row = db.execute( row = db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id) select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
).scalar_one_or_none() ).scalar_one_or_none()
if not keep: if not position:
# Nothing meaningful to store: drop a status-less row entirely, otherwise just # Nothing meaningful to store: drop a status-less row entirely, otherwise just
# zero the position (a saved/hidden video keeps its status). # zero the position (a saved/hidden video keeps its status).
if row is not None: if row is not None:
+121
View File
@@ -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) == []
+8 -2
View File
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
return TestClient(app) return TestClient(app)
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"]) @pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"]) @pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
def test_a_non_finite_position_is_refused(client, path, raw): def test_a_non_finite_position_is_refused(client, path, raw):
r = client.post( r = client.post(
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"]) @pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"]) @pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field): def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback.""" """Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
+4 -4
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn from app.routes.downloads import AdminSetQuotaIn
from app.routes.feed import VideoProgressIn from app.routes._common import ProgressIn
from app.routes.setup import SetupAdminIn from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn from app.routes.tags import TagUpdateIn
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
def test_progress_accepts_fractional_seconds(): def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the # The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path. # handler) — a plain `int` field would 422 on the progress hot path.
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7 assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12 assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
VideoProgressIn(position_seconds="not-a-number") ProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". --- # --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
+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 **Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one. Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
"Play from the beginning", and its picture carries the cards' progress bar.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the - **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13] live). [WCAG 1.4.13]
@@ -541,7 +543,9 @@ of a full-width panel and nothing on the right.
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
there when it arrives, as long as the focus still rests on the ✕; there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read, `action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG] browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
focused ✕ closed the film. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the - **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise `interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
@@ -706,3 +710,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-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it | | 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules | | 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
]); ]);
}); });
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => { it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")]; mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
+16 -4
View File
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null); const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null); const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [playJob, setPlayJob] = useState<DownloadJob | null>(null); // What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null); const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null); const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
<IconBtn <IconBtn
open open
onClick={() => setPlayJob(job)} onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")} title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
> >
<PlayCircle className="w-4 h-4" /> <PlayCircle className="w-4 h-4" />
</IconBtn> </IconBtn>
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
<IconBtn <IconBtn
open open
onClick={() => setPlayJob(job)} onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")} title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
> >
<PlayCircle className="w-4 h-4" /> <PlayCircle className="w-4 h-4" />
</IconBtn> </IconBtn>
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />} {metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />} {playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && ( {errorJob && (
<ErrorModal <ErrorModal
@@ -5,7 +5,9 @@
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n // list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here. // is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest"; import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react"; import { render as rtlRender, cleanup } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose })); vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import LibraryPlayer from "./LibraryPlayer"; import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const baseJob: DownloadJob = { const baseJob: DownloadJob = {
id: 7, id: 7,
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
}, },
}); });
afterEach(cleanup); // The player writes its resume point into the library listings' cache, so it sits under a client.
const render = (ui: ReactElement, client = new QueryClient()) =>
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
function loaded(duration = 600) {
const video = document.body.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
describe("LibraryPlayer", () => { describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => { it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
@@ -130,4 +153,51 @@ describe("LibraryPlayer", () => {
); );
expect(document.body.querySelector("audio")).not.toBeNull(); expect(document.body.querySelector("audio")).not.toBeNull();
}); });
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.parentElement;
});
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
const panel = document.activeElement as HTMLElement;
expect(panel.tagName).toBe("DIV");
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
});
it("opens where the viewer stopped — or from the start when asked", () => {
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
render(<LibraryPlayer job={job} onClose={() => {}} />);
expect(loaded().currentTime).toBe(300);
cleanup();
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
position_seconds: 125,
});
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 125.4;
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600);
});
}); });
+50 -8
View File
@@ -1,10 +1,15 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog"; import { useMediaLogRef } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { keptPosition, useResume } from "../lib/resume";
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin // In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain // YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware // browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be // /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
// any codec (best-effort playback). // any codec (best-effort playback).
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) { //
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
// controls and the basic keys drive it like the other players.
export default function LibraryPlayer({
job,
fromStart = false,
onClose,
}: {
job: DownloadJob;
fromStart?: boolean;
onClose: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]); const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads — // Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify // WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
@@ -32,16 +50,40 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// the fallback. Same precedence as the library-row thumbnail. // the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined); const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library"); const logMedia = useMediaLogRef("library");
const videoRef = useCallback( // The element as STATE, so the session, the keys and the resume bind to it once it exists.
(el: HTMLVideoElement | null) => { const [media, setMedia] = useState<HTMLMediaElement | null>(null);
exposeVideoToBackgroundPlay(el); const mediaRef = useCallback(
(el: HTMLMediaElement | null) => {
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
logMedia(el); logMedia(el);
setMedia(el);
}, },
[logMedia], [logMedia],
); );
const title = jobTitle(job);
useMediaSession(media, {
title,
artist: job.display_uploader ?? asset?.uploader ?? null,
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch, which may land before the closing beacon does.
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of [qk.downloads(), qk.downloadsShared()]) {
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);
else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
return ( return (
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl"> <Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? ( {isAudio ? (
<div className="flex flex-col items-center gap-4 py-2"> <div className="flex flex-col items-center gap-4 py-2">
{poster ? ( {poster ? (
@@ -51,13 +93,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border" className="w-40 h-40 rounded-xl object-cover border border-border"
/> />
) : null} ) : null}
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full"> <audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")} {t("downloads.player.unsupported")}
</audio> </audio>
</div> </div>
) : ( ) : (
<video <video
ref={videoRef} ref={mediaRef}
src={fileUrl} src={fileUrl}
poster={poster} poster={poster}
controls controls
+26
View File
@@ -56,6 +56,7 @@ import { HlsEngine } from "../lib/hlsEngine";
import { createHlsPort } from "../lib/hlsPort"; import { createHlsPort } from "../lib/hlsPort";
import { enabled, log } from "../lib/log"; import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog"; import { logMediaElement } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { UpNextCard } from "./UpNextCard"; import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster"; import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder"; import { useOverlayOrder } from "./ui/overlayOrder";
@@ -578,6 +579,30 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
); );
const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]); const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]);
// The OS's media controls (lock screen, headset, media keys) drive the player through its own seek
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
// begins at the resume point), and a seek outside the stream restarts it.
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useMediaSession(
videoEl,
detail && {
title:
detail.kind === "episode" && detail.season_number != null
? `S${detail.season_number}·E${detail.episode_number} — ${detail.title}`
: detail.title,
artist:
detail.kind === "episode" ? detail.show_title : detail.year ? String(detail.year) : null,
artwork: api.plexImageUrl(id),
},
{
play: () => requestPlay(true),
pause: () => requestPlay(false),
seekTo: (s) => seekTo(s),
position: () => engine.playhead(),
duration: () => durationRef.current,
},
);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session. // Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({ const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
videoRef, videoRef,
@@ -1095,6 +1120,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<video <video
ref={(el) => { ref={(el) => {
videoRef.current = el; videoRef.current = el;
setVideoEl(el);
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay). // So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
exposeVideoToBackgroundPlay(el); exposeVideoToBackgroundPlay(el);
}} }}
@@ -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 { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip"; import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
import { LS, readJSON, writeJSON } from "../lib/storage"; import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http"; import { send } from "../lib/http";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny // watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference). // self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
const init: MediaMetadataInit = {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
};
try {
navigator.mediaSession.metadata = new MediaMetadata(init);
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
}
type Meta = { type Meta = {
needs_password: boolean; needs_password: boolean;
title?: string | null; title?: string | null;
@@ -95,13 +64,24 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const errorId = useId(); const errorId = useId();
const [unlocking, setUnlocking] = useState(false); const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to // Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// both without a per-element typed ref. // the resume bind to it once it exists.
const mediaRef = useRef<HTMLMediaElement | null>(null); const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => { useMediaSession(
mediaRef.current = el; status === "ready" ? media : null,
}; meta && {
useMediaSession(mediaRef, meta, status === "ready"); title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser // Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default. // (this page has no account); an unreadable storage just means the default.
+6 -1
View File
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle"; import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
</div> </div>
); );
} }
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
return ( return (
<div className={THUMB_BOX}> <div className={`${THUMB_BOX} relative`}>
<MediaProgress pct={pct} />
{src ? ( {src ? (
<img <img
src={src} src={src}
@@ -15,6 +15,8 @@ import {
type KeyScope, type KeyScope,
} from "../../lib/keymap"; } from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
import { useMediaKeys } from "../../lib/mediaKeys";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
pressH(); pressH();
expect(descriptions()).toContain("keys.episode"); expect(descriptions()).toContain("keys.episode");
}); });
// The library player's F fills the screen with a video; an audio file has no picture, so the
// sheet over an audio file does not offer it. Through the real hook, which publishes the answer.
it("offers Fullscreen over a video and not over an audio file", () => {
function Player({ tag }: { tag: "video" | "audio" }) {
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaKeys(media);
return tag === "video" ? <video ref={setMedia} /> : <audio ref={setMedia} />;
}
const { unmount } = render(
<Host>
<Player tag="audio" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.playPause");
expect(descriptions()).not.toContain("keys.fullscreen");
unmount();
closeKeyboardHelp();
render(
<Host>
<Player tag="video" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.fullscreen");
});
}); });
+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, /** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or * or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button; * browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first * the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no
* button). Required, so every dialog states its purpose instead of inheriting "the first
* focusable", which is always the ✕. */ * focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action"; export type ModalFocus = "field" | "close" | "action" | "player";
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that // The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller. // owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
@@ -101,6 +102,7 @@ export default function Modal({
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's // Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over. // first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
initial: () => initial: () =>
(initialFocus === "player" ? panelRef.current : null) ??
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ?? (initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
(closeRef.current?.offsetParent != null ? closeRef.current : null), (closeRef.current?.offsetParent != null ? closeRef.current : null),
}); });
+26 -9
View File
@@ -13,6 +13,7 @@ import {
RotateCcw, RotateCcw,
Scissors, Scissors,
Share2, Share2,
SkipBack,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -76,7 +77,7 @@ export function useDownloadActions({
onEditDetails, onEditDetails,
onManageShares, onManageShares,
}: { }: {
onPlay: (job: DownloadJob) => void; onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void; onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void; onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void; onManageShares: (job: DownloadJob) => void;
@@ -128,12 +129,28 @@ export function useDownloadActions({
}; };
const icon = (I: typeof Play) => <I className="w-4 h-4" />; const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({ // A started item continues, and can also start over — the card menus' words and glyph (C5).
id: "play", const play = (job: DownloadJob): ActionMenuItem[] => {
label: t("downloads.actions.play"), const started = (job.position_seconds ?? 0) > 0;
icon: icon(PlayCircle), return [
onSelect: () => onPlay(job), {
}); id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an // The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is. // attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({ const save = (job: DownloadJob): ActionMenuItem => ({
@@ -200,7 +217,7 @@ export function useDownloadActions({
// The row's own buttons first, in the row's order: sharing the link is the library's main // The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file. // errand, ahead of saving the file.
[ [
...(a.play ? [play(job)] : []), ...(a.play ? play(job) : []),
{ {
id: "share", id: "share",
label: t("downloads.actions.share"), label: t("downloads.actions.share"),
@@ -269,7 +286,7 @@ export function useDownloadActions({
// Same media_kind + can_download gate as the library, via the shared helper. // Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job); const a = sharedRowActions(job);
return menuGroups( return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])], [...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [], a.edit ? [edit(job)] : [],
copies(job), copies(job),
[ [
+31 -1
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the // Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only // phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core. // reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, uploadReq, getActiveAccount } from "./core"; import { beacon, req, uploadReq, getActiveAccount } from "./core";
export interface DownloadSpec { export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -97,6 +97,9 @@ export interface DownloadJob {
asset: DownloadAsset | null; asset: DownloadAsset | null;
shared?: boolean; shared?: boolean;
user_email?: string; user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
position_seconds?: number;
} }
export interface ShareRecipient { export interface ShareRecipient {
@@ -274,6 +277,33 @@ export const downloadsApi = {
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }), req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> => revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }), req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
saveDownloadProgress: (
id: number,
positionSeconds: number,
durationSeconds: number,
): Promise<{ ok: true; job_id: number; position_seconds: number }> =>
req(
`/api/downloads/${id}/progress`,
{
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true },
),
// The same, as a keepalive POST that survives the page going away (a reload, a closed tab).
saveDownloadProgressBeacon: (
id: number,
positionSeconds: number,
durationSeconds: number,
): void =>
beacon(`/api/downloads/${id}/progress`, {
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active // A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) — // account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab // otherwise the download resolves to the session-default account and 404s for a per-tab
+24 -6
View File
@@ -36,6 +36,7 @@ export type KeyScope =
| "contextMenu" | "contextMenu"
| "ytPlayer" | "ytPlayer"
| "plexPlayer" | "plexPlayer"
| "mediaPlayer"
| "plexBrowse"; | "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope); export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */ /** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode"; type ShortcutWhen = "episode" | "video";
export interface Shortcut { export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */ /** Unique within its scope; the handler key `dispatch()` demands. */
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
allowInInput: true, allowInInput: true,
}, },
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Plex browser (drill-down) ---------------------------------------------------------- // --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" }, { id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[]; ] as const satisfies readonly Shortcut[];
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean }; export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({}); const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The /** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
* sheet lives in App and cannot ask, so the surface tells it. */ * library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void { export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode; const { episode, video } = conditions;
useEffect(() => { useEffect(() => {
helpConditions.set({ episode }); helpConditions.set({ episode, video });
return () => helpConditions.set({}); return () => helpConditions.set({});
}, [episode]); }, [episode, video]);
} }
// ── The module's search box ─────────────────────────────────────────────────────────────────── // ── The module's search box ───────────────────────────────────────────────────────────────────
+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]);
}
+181
View File
@@ -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);
});
});
+124
View File
@@ -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<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: // 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. // that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat", 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; } as const;
// --- per-account scoping ----------------------------------------------------------------- // --- per-account scoping -----------------------------------------------------------------