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:
@@ -102,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||||
|
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ blocker and SponsorBlock keep working.
|
|||||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
||||||
- **In-app player** with resume, plus keyboard/scroll controls.
|
- **In-app player** with resume, plus keyboard/scroll controls.
|
||||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
||||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
|
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
||||||
to your device, share with another user, or hand out a public watch link.
|
where you left off, then save to your device, share with another user, or hand out a public watch
|
||||||
|
link.
|
||||||
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
||||||
movies and shows alongside your feed, with watch state kept in sync.
|
movies and shows alongside your feed, with watch state kept in sync.
|
||||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -743,14 +743,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 37,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""library: a resume point per user and library item
|
||||||
|
|
||||||
|
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
|
||||||
|
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
|
||||||
|
status: the library has no "watched". It starts empty; the downgrade drops it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0099_library_states"
|
||||||
|
down_revision: str | None = "0098_drop_demo_account"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"library_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"job_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("position_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_library_states_job_id", table_name="library_states")
|
||||||
|
op.drop_table("library_states")
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,7 +17,8 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.mailtext import lang_for_user
|
from app.mailtext import lang_for_user
|
||||||
@@ -39,11 +40,19 @@ from app.models import (
|
|||||||
DownloadProfile,
|
DownloadProfile,
|
||||||
DownloadQuota,
|
DownloadQuota,
|
||||||
DownloadShare,
|
DownloadShare,
|
||||||
|
LibraryState,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
User,
|
||||||
display_text,
|
display_text,
|
||||||
)
|
)
|
||||||
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
from app.routes._common import (
|
||||||
|
ProgressIn,
|
||||||
|
capped_stream_to,
|
||||||
|
get_or_404,
|
||||||
|
kept_position,
|
||||||
|
not_found,
|
||||||
|
owned_or_404,
|
||||||
|
)
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
@@ -165,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
|
|||||||
return {a.id: a for a in rows}
|
return {a.id: a for a in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
|
||||||
|
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
|
||||||
|
ids = [j.id for j in jobs]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(
|
||||||
|
select(LibraryState.job_id, LibraryState.position_seconds).where(
|
||||||
|
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {job_id: pos for job_id, pos in rows}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
||||||
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
||||||
every single-job handler shares."""
|
every single-job handler shares."""
|
||||||
@@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = _assets_for(db, jobs)
|
||||||
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
|
positions = _positions_for(db, user, jobs)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||||
|
"position_seconds": positions.get(j.id, 0),
|
||||||
|
}
|
||||||
|
for j in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage")
|
@router.get("/usage")
|
||||||
@@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = _assets_for(db, jobs)
|
||||||
|
positions = _positions_for(db, user, jobs)
|
||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
data = _serialize(j, assets.get(j.asset_id))
|
data = _serialize(j, assets.get(j.asset_id))
|
||||||
data["shared"] = True
|
data["shared"] = True
|
||||||
|
data["position_seconds"] = positions.get(j.id, 0)
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -825,6 +856,39 @@ def download_file(
|
|||||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/progress")
|
||||||
|
def set_library_progress(
|
||||||
|
job_id: int,
|
||||||
|
body: ProgressIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
|
||||||
|
one shared with them). A position too early or too close to the end clears it instead."""
|
||||||
|
_accessible_job(db, user, job_id)
|
||||||
|
position = kept_position(body)
|
||||||
|
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
|
||||||
|
if not position:
|
||||||
|
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
|
||||||
|
# one stop may both clear it, and the second then deletes nothing rather than failing.
|
||||||
|
db.execute(delete(LibraryState).where(mine))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "job_id": job_id, "position_seconds": 0}
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
|
||||||
|
# read-then-insert that the second would fail on the unique key.
|
||||||
|
db.execute(
|
||||||
|
pg_insert(LibraryState)
|
||||||
|
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
constraint="uq_library_user_job",
|
||||||
|
set_={"position_seconds": position, "progress_updated_at": now},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "job_id": job_id, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}/storyboard")
|
@router.get("/{job_id}/storyboard")
|
||||||
def storyboard_meta(
|
def storyboard_meta(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
|||||||
+11
-34
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,14 +26,19 @@ from app.feedquery import (
|
|||||||
windowed_relevance_rows,
|
windowed_relevance_rows,
|
||||||
)
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
POSITION_MAX_S,
|
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404, youtube_refusal, not_found
|
from app.routes._common import (
|
||||||
|
ProgressIn,
|
||||||
|
get_or_404,
|
||||||
|
kept_position,
|
||||||
|
not_found,
|
||||||
|
youtube_refusal,
|
||||||
|
)
|
||||||
from app.sync.videos import parse_iso8601_duration
|
from app.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
|
|||||||
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||||
VALID_STATES = {"new", "watched", "hidden"}
|
VALID_STATES = {"new", "watched", "hidden"}
|
||||||
|
|
||||||
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
|
||||||
# really start" and within this of the end are "basically finished" — neither is worth
|
|
||||||
# storing, so we clear the position instead (the video shows no progress bar).
|
|
||||||
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
|
|
||||||
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
|
|
||||||
PROGRESS_MIN_SECONDS = 5
|
|
||||||
FINISH_MARGIN_SECONDS = 10
|
|
||||||
|
|
||||||
|
|
||||||
# Shared query parameters for /feed and /feed/count.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
def _feed_params(
|
def _feed_params(
|
||||||
@@ -326,23 +323,10 @@ def clear_video_state(
|
|||||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
||||||
|
|
||||||
|
|
||||||
class VideoProgressIn(BaseModel):
|
|
||||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
|
||||||
# one into a 500.
|
|
||||||
model_config = ConfigDict(allow_inf_nan=False)
|
|
||||||
|
|
||||||
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
|
|
||||||
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
|
|
||||||
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
|
|
||||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
|
||||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
|
||||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/progress")
|
@router.post("/videos/{video_id}/progress")
|
||||||
def set_video_progress(
|
def set_video_progress(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
body: VideoProgressIn,
|
body: ProgressIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -350,22 +334,15 @@ def set_video_progress(
|
|||||||
Stores a per-user position on the video_states row without touching watch status, so a
|
Stores a per-user position on the video_states row without touching watch status, so a
|
||||||
partially-watched video can render a progress bar and match the "in progress" filter.
|
partially-watched video can render a progress bar and match the "in progress" filter.
|
||||||
Trivially-early and near-finished positions clear the position rather than store it."""
|
Trivially-early and near-finished positions clear the position rather than store it."""
|
||||||
position = int(body.position_seconds)
|
position = kept_position(body)
|
||||||
duration = int(body.duration_seconds)
|
|
||||||
if position < 0:
|
|
||||||
position = 0
|
|
||||||
|
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
|
|
||||||
# Decide whether this position is worth keeping (else clear it).
|
|
||||||
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
|
|
||||||
keep = position >= PROGRESS_MIN_SECONDS and not near_end
|
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if not keep:
|
if not position:
|
||||||
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
||||||
# zero the position (a saved/hidden video keeps its status).
|
# zero the position (a saved/hidden video keeps its status).
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
|
|||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
|
||||||
|
)
|
||||||
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
||||||
def test_a_non_finite_position_is_refused(client, path, raw):
|
def test_a_non_finite_position_is_refused(client, path, raw):
|
||||||
r = client.post(
|
r = client.post(
|
||||||
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
|
|||||||
assert r.status_code == 422, r.text
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
|
||||||
|
)
|
||||||
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
||||||
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
||||||
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
|
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
||||||
from app.routes.downloads import AdminSetQuotaIn
|
from app.routes.downloads import AdminSetQuotaIn
|
||||||
from app.routes.feed import VideoProgressIn
|
from app.routes._common import ProgressIn
|
||||||
from app.routes.setup import SetupAdminIn
|
from app.routes.setup import SetupAdminIn
|
||||||
from app.routes.tags import TagUpdateIn
|
from app.routes.tags import TagUpdateIn
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
|
|||||||
def test_progress_accepts_fractional_seconds():
|
def test_progress_accepts_fractional_seconds():
|
||||||
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
||||||
# handler) — a plain `int` field would 422 on the progress hot path.
|
# handler) — a plain `int` field would 422 on the progress hot path.
|
||||||
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
|
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
|
||||||
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
|
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VideoProgressIn(position_seconds="not-a-number")
|
ProgressIn(position_seconds="not-a-number")
|
||||||
|
|
||||||
|
|
||||||
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
|
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
|
||||||
|
|||||||
+7
-2
@@ -528,7 +528,9 @@ of a full-width panel and nothing on the right.
|
|||||||
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
||||||
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
||||||
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
||||||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
|
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
|
||||||
|
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
|
||||||
|
"Play from the beginning", and its picture carries the cards' progress bar.
|
||||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
||||||
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||||
live). [WCAG 1.4.13]
|
live). [WCAG 1.4.13]
|
||||||
@@ -541,7 +543,9 @@ of a full-width panel and nothing on the right.
|
|||||||
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
|
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
|
||||||
there when it arrives, as long as the focus still rests on the ✕;
|
there when it arrives, as long as the focus still rests on the ✕;
|
||||||
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
||||||
browse or manage. [WAI-ARIA APG]
|
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
|
||||||
|
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
|
||||||
|
focused ✕ closed the film. [WAI-ARIA APG]
|
||||||
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
||||||
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
||||||
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
||||||
@@ -706,3 +710,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { Chip } from "./ui/Chip";
|
import { Chip } from "./ui/Chip";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
|
import { useMediaSession } from "../lib/mediaSession";
|
||||||
|
import { useMediaKeys } from "../lib/mediaKeys";
|
||||||
|
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
|
||||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||||
import { send } from "../lib/http";
|
import { send } from "../lib/http";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
|
|||||||
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
|
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
|
||||||
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
|
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
|
||||||
|
|
||||||
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
|
|
||||||
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
|
|
||||||
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
|
|
||||||
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
|
||||||
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
|
||||||
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
|
||||||
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
|
|
||||||
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
|
|
||||||
// so it drives either the video or the audio-only player.
|
|
||||||
function useMediaSession(
|
|
||||||
videoRef: React.RefObject<HTMLMediaElement | null>,
|
|
||||||
meta: Meta | null,
|
|
||||||
ready: boolean,
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
|
||||||
const video = videoRef.current;
|
|
||||||
const init: MediaMetadataInit = {
|
|
||||||
title: meta.title || "Video",
|
|
||||||
artist: meta.uploader || "Siftlode",
|
|
||||||
artwork: meta.poster_url
|
|
||||||
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata(init);
|
|
||||||
navigator.mediaSession.setActionHandler("play", () => void video?.play());
|
|
||||||
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
|
|
||||||
} catch {
|
|
||||||
/* MediaMetadata / handlers unsupported — ignore */
|
|
||||||
}
|
|
||||||
}, [ready, meta, videoRef]);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Meta = {
|
type Meta = {
|
||||||
needs_password: boolean;
|
needs_password: boolean;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -95,13 +64,24 @@ export default function WatchPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const errorId = useId();
|
const errorId = useId();
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||||
// both without a per-element typed ref.
|
// the resume bind to it once it exists.
|
||||||
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||||
const setMedia = (el: HTMLMediaElement | null) => {
|
useMediaSession(
|
||||||
mediaRef.current = el;
|
status === "ready" ? media : null,
|
||||||
};
|
meta && {
|
||||||
useMediaSession(mediaRef, meta, status === "ready");
|
title: meta.title || "Video",
|
||||||
|
artist: meta.uploader || "Siftlode",
|
||||||
|
artwork: meta.poster_url,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
useMediaKeys(media);
|
||||||
|
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
|
||||||
|
// Read once per link, at render: the point a later checkpoint writes must not move the start.
|
||||||
|
const [start] = useState(() => (token ? linkPoint(token) : 0));
|
||||||
|
useResume(media, token || null, start, (position, duration) =>
|
||||||
|
saveLinkPoint(token, position, duration),
|
||||||
|
);
|
||||||
|
|
||||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||||
// (this page has no account); an unreadable storage just means the default.
|
// (this page has no account); an unreadable storage just means the default.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
|
|||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
||||||
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
import { MediaProgress } from "./ui/MediaCard";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// How far into it the viewer is, the media cards' bar.
|
||||||
|
const duration = job.asset?.duration_s ?? 0;
|
||||||
|
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div className={THUMB_BOX}>
|
<div className={`${THUMB_BOX} relative`}>
|
||||||
|
<MediaProgress pct={pct} />
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
Share2,
|
Share2,
|
||||||
|
SkipBack,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -76,7 +77,7 @@ export function useDownloadActions({
|
|||||||
onEditDetails,
|
onEditDetails,
|
||||||
onManageShares,
|
onManageShares,
|
||||||
}: {
|
}: {
|
||||||
onPlay: (job: DownloadJob) => void;
|
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
|
||||||
onEdit: (job: DownloadJob) => void;
|
onEdit: (job: DownloadJob) => void;
|
||||||
onEditDetails: (job: DownloadJob) => void;
|
onEditDetails: (job: DownloadJob) => void;
|
||||||
onManageShares: (job: DownloadJob) => void;
|
onManageShares: (job: DownloadJob) => void;
|
||||||
@@ -128,12 +129,28 @@ export function useDownloadActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
|
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
|
||||||
const play = (job: DownloadJob): ActionMenuItem => ({
|
// A started item continues, and can also start over — the card menus' words and glyph (C5).
|
||||||
id: "play",
|
const play = (job: DownloadJob): ActionMenuItem[] => {
|
||||||
label: t("downloads.actions.play"),
|
const started = (job.position_seconds ?? 0) > 0;
|
||||||
icon: icon(PlayCircle),
|
return [
|
||||||
onSelect: () => onPlay(job),
|
{
|
||||||
});
|
id: "play",
|
||||||
|
label: started ? t("card.continue") : t("downloads.actions.play"),
|
||||||
|
icon: icon(PlayCircle),
|
||||||
|
onSelect: () => onPlay(job),
|
||||||
|
},
|
||||||
|
...(started
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "restart",
|
||||||
|
label: t("card.restartTitle"),
|
||||||
|
icon: icon(SkipBack),
|
||||||
|
onSelect: () => onPlay(job, true),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
};
|
||||||
// The same link the row's save button is, followed inside the click: the server answers it as an
|
// The same link the row's save button is, followed inside the click: the server answers it as an
|
||||||
// attachment, so the page stays where it is.
|
// attachment, so the page stays where it is.
|
||||||
const save = (job: DownloadJob): ActionMenuItem => ({
|
const save = (job: DownloadJob): ActionMenuItem => ({
|
||||||
@@ -200,7 +217,7 @@ export function useDownloadActions({
|
|||||||
// The row's own buttons first, in the row's order: sharing the link is the library's main
|
// The row's own buttons first, in the row's order: sharing the link is the library's main
|
||||||
// errand, ahead of saving the file.
|
// errand, ahead of saving the file.
|
||||||
[
|
[
|
||||||
...(a.play ? [play(job)] : []),
|
...(a.play ? play(job) : []),
|
||||||
{
|
{
|
||||||
id: "share",
|
id: "share",
|
||||||
label: t("downloads.actions.share"),
|
label: t("downloads.actions.share"),
|
||||||
@@ -269,7 +286,7 @@ export function useDownloadActions({
|
|||||||
// Same media_kind + can_download gate as the library, via the shared helper.
|
// Same media_kind + can_download gate as the library, via the shared helper.
|
||||||
const a = sharedRowActions(job);
|
const a = sharedRowActions(job);
|
||||||
return menuGroups(
|
return menuGroups(
|
||||||
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
|
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
|
||||||
a.edit ? [edit(job)] : [],
|
a.edit ? [edit(job)] : [],
|
||||||
copies(job),
|
copies(job),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,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
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type KeyScope =
|
|||||||
| "contextMenu"
|
| "contextMenu"
|
||||||
| "ytPlayer"
|
| "ytPlayer"
|
||||||
| "plexPlayer"
|
| "plexPlayer"
|
||||||
|
| "mediaPlayer"
|
||||||
| "plexBrowse";
|
| "plexBrowse";
|
||||||
|
|
||||||
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
||||||
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
|||||||
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||||
|
|
||||||
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
||||||
type ShortcutWhen = "episode";
|
type ShortcutWhen = "episode" | "video";
|
||||||
|
|
||||||
export interface Shortcut {
|
export interface Shortcut {
|
||||||
/** Unique within its scope; the handler key `dispatch()` demands. */
|
/** Unique within its scope; the handler key `dispatch()` demands. */
|
||||||
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
|
|||||||
allowInInput: true,
|
allowInInput: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- a player on the browser's own media element (the library's, a shared link's) -----------
|
||||||
|
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
|
||||||
|
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
|
||||||
|
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
|
||||||
|
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
|
||||||
|
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
|
||||||
|
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
|
||||||
|
// does not promise it.
|
||||||
|
{
|
||||||
|
id: "fullscreen",
|
||||||
|
scope: "mediaPlayer",
|
||||||
|
keys: ["f"],
|
||||||
|
labelKey: "keys.fullscreen",
|
||||||
|
when: "video",
|
||||||
|
},
|
||||||
|
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
|
||||||
|
|
||||||
// --- the Plex browser (drill-down) ----------------------------------------------------------
|
// --- the Plex browser (drill-down) ----------------------------------------------------------
|
||||||
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
|
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
|
||||||
] as const satisfies readonly Shortcut[];
|
] as const satisfies readonly Shortcut[];
|
||||||
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
|
|||||||
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
||||||
const helpConditions = createStore<HelpConditions>({});
|
const helpConditions = createStore<HelpConditions>({});
|
||||||
|
|
||||||
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
|
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
|
||||||
* sheet lives in App and cannot ask, so the surface tells it. */
|
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
|
||||||
export function useHelpConditions(conditions: HelpConditions): void {
|
export function useHelpConditions(conditions: HelpConditions): void {
|
||||||
const episode = conditions.episode;
|
const { episode, video } = conditions;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
helpConditions.set({ episode });
|
helpConditions.set({ episode, video });
|
||||||
return () => helpConditions.set({});
|
return () => helpConditions.set({});
|
||||||
}, [episode]);
|
}, [episode, video]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The basic keys of a player on the browser's own media element (the library's, a shared link's):
|
||||||
|
// Space/k play and pause — but Space on a focused button presses the button — the arrows step 5 s
|
||||||
|
// inside the item, m mutes, f fills the screen with a video (an audio file has no picture). While the
|
||||||
|
// element itself has the focus, Space and the arrows are its own controls' (they act on them anyway,
|
||||||
|
// so a second action from here was a double play/pause and a double step).
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
const fs = vi.hoisted(() => ({ requestFullscreen: vi.fn(), exitFullscreen: vi.fn() }));
|
||||||
|
vi.mock("./fullscreen", () => ({
|
||||||
|
fullscreenElement: () => null,
|
||||||
|
requestFullscreen: fs.requestFullscreen,
|
||||||
|
exitFullscreen: fs.exitFullscreen,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MEDIA_STEP_S, useMediaKeys } from "./mediaKeys";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function media(tag: "video" | "audio" = "video", duration = 100) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
document.body.append(el);
|
||||||
|
let paused = true;
|
||||||
|
Object.defineProperty(el, "duration", { value: duration, configurable: true });
|
||||||
|
Object.defineProperty(el, "paused", { get: () => paused, configurable: true });
|
||||||
|
vi.spyOn(el, "play").mockImplementation(() => {
|
||||||
|
paused = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
vi.spyOn(el, "pause").mockImplementation(() => {
|
||||||
|
paused = true;
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(key: string, target: EventTarget = document.body): KeyboardEvent {
|
||||||
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||||
|
target.dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useMediaKeys", () => {
|
||||||
|
it("Space and k play and pause, and take the key from the page", () => {
|
||||||
|
const el = media();
|
||||||
|
renderHook(() => useMediaKeys(el));
|
||||||
|
const e = press(" ");
|
||||||
|
expect(el.play).toHaveBeenCalledOnce();
|
||||||
|
expect(e.defaultPrevented).toBe(true);
|
||||||
|
press("k");
|
||||||
|
expect(el.pause).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on the focused element, Space and the arrows are left to its own controls; k still is ours", () => {
|
||||||
|
const el = media("video", 100);
|
||||||
|
renderHook(() => useMediaKeys(el));
|
||||||
|
el.currentTime = 50;
|
||||||
|
const space = press(" ", el);
|
||||||
|
const arrow = press("ArrowRight", el);
|
||||||
|
expect(el.play).not.toHaveBeenCalled();
|
||||||
|
expect(el.currentTime).toBe(50);
|
||||||
|
expect([space.defaultPrevented, arrow.defaultPrevented]).toEqual([false, false]);
|
||||||
|
press("k", el);
|
||||||
|
expect(el.play).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Space on a focused button presses the button instead", () => {
|
||||||
|
const el = media();
|
||||||
|
const button = document.createElement("button");
|
||||||
|
document.body.append(button);
|
||||||
|
renderHook(() => useMediaKeys(el));
|
||||||
|
const e = press(" ", button);
|
||||||
|
expect(el.play).not.toHaveBeenCalled();
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the arrows step inside the item", () => {
|
||||||
|
const el = media("video", 100);
|
||||||
|
renderHook(() => useMediaKeys(el));
|
||||||
|
el.currentTime = 2;
|
||||||
|
press("ArrowLeft");
|
||||||
|
expect(el.currentTime).toBe(0);
|
||||||
|
el.currentTime = 50;
|
||||||
|
press("ArrowRight");
|
||||||
|
expect(el.currentTime).toBe(50 + MEDIA_STEP_S);
|
||||||
|
el.currentTime = 98;
|
||||||
|
press("ArrowRight");
|
||||||
|
expect(el.currentTime).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("m mutes and unmutes", () => {
|
||||||
|
const el = media();
|
||||||
|
renderHook(() => useMediaKeys(el));
|
||||||
|
press("m");
|
||||||
|
expect(el.muted).toBe(true);
|
||||||
|
press("m");
|
||||||
|
expect(el.muted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("f fills the screen with a video, and leaves an audio file alone", () => {
|
||||||
|
const v = media("video");
|
||||||
|
const first = renderHook(() => useMediaKeys(v));
|
||||||
|
press("f");
|
||||||
|
expect(fs.requestFullscreen).toHaveBeenCalledWith(v);
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
const a = media("audio");
|
||||||
|
renderHook(() => useMediaKeys(a));
|
||||||
|
const e = press("f");
|
||||||
|
expect(fs.requestFullscreen).toHaveBeenCalledOnce();
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays out of a text field, and goes with the player", () => {
|
||||||
|
const el = media();
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.append(input);
|
||||||
|
const { unmount } = renderHook(() => useMediaKeys(el));
|
||||||
|
press(" ", input);
|
||||||
|
expect(el.play).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
press(" ");
|
||||||
|
expect(el.play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The keys of a player built on the browser's own media element — the library's and a shared link's:
|
||||||
|
// the keymap's `mediaPlayer` rows (play/pause, a 5 s step, fullscreen, mute), bound to the element.
|
||||||
|
// Its own controls do the rest; Escape stays with the dialog around it.
|
||||||
|
//
|
||||||
|
// While the element ITSELF has the focus (a click on its controls puts it there), Space and the
|
||||||
|
// arrows are its own: the browser's controls act on them whatever a listener does with the keydown,
|
||||||
|
// so ours as well played and paused on one Space, and stepped twice on one arrow (measured in Chrome).
|
||||||
|
import { useEffect, useEffectEvent } from "react";
|
||||||
|
|
||||||
|
import { dispatch, isActivationTarget, useHelpConditions, useKeyScope } from "./keymap";
|
||||||
|
import { exitFullscreen, fullscreenElement, requestFullscreen } from "./fullscreen";
|
||||||
|
|
||||||
|
export const MEDIA_STEP_S = 5;
|
||||||
|
|
||||||
|
/** Bind the `mediaPlayer` keys to `media` while it is mounted. */
|
||||||
|
export function useMediaKeys(media: HTMLMediaElement | null): void {
|
||||||
|
useKeyScope("mediaPlayer", media != null);
|
||||||
|
useHelpConditions({ video: media instanceof HTMLVideoElement });
|
||||||
|
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||||
|
if (!media) return;
|
||||||
|
const step = (ev: KeyboardEvent, by: number) => {
|
||||||
|
if (ev.target === media) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const end = Number.isFinite(media.duration) ? media.duration : Infinity;
|
||||||
|
media.currentTime = Math.min(end, Math.max(0, media.currentTime + by));
|
||||||
|
};
|
||||||
|
const ownKey = (ev: KeyboardEvent) => ev.target === media && ev.key !== "k";
|
||||||
|
dispatch(e, "mediaPlayer", {
|
||||||
|
playPause: (ev) => {
|
||||||
|
// Space on a focused button presses it (the dialog's ✕, a link's Download), as in every
|
||||||
|
// player here.
|
||||||
|
if (isActivationTarget(ev) || ownKey(ev)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (media.paused) void media.play().catch(() => {});
|
||||||
|
else media.pause();
|
||||||
|
},
|
||||||
|
seekBack: (ev) => step(ev, -MEDIA_STEP_S),
|
||||||
|
seekFwd: (ev) => step(ev, MEDIA_STEP_S),
|
||||||
|
fullscreen: (ev) => {
|
||||||
|
// An audio file has no picture to fill the screen with.
|
||||||
|
if (!(media instanceof HTMLVideoElement)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (fullscreenElement()) exitFullscreen();
|
||||||
|
else requestFullscreen(media);
|
||||||
|
},
|
||||||
|
mute: (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
media.muted = !media.muted;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media) return;
|
||||||
|
const handler = (e: KeyboardEvent) => onKey(e);
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [media]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The OS's media controls: what is playing (title, line, picture), the buttons driving the player —
|
||||||
|
// through ITS controls when it has its own clock and seek (the Plex player), else the element's —
|
||||||
|
// the progress bar anchored to the player's position, and a clean hand-over: unmounting clears only
|
||||||
|
// a session that is still ours.
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { renderHook, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { SESSION_SKIP_S, useMediaSession, type MediaSessionControls } from "./mediaSession";
|
||||||
|
|
||||||
|
type Handler = (d: MediaSessionActionDetails) => void;
|
||||||
|
let handlers: Map<string, Handler | null>;
|
||||||
|
let session: {
|
||||||
|
metadata: MediaMetadata | null;
|
||||||
|
setActionHandler: (a: string, h: Handler | null) => void;
|
||||||
|
setPositionState: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
handlers = new Map();
|
||||||
|
session = {
|
||||||
|
metadata: null,
|
||||||
|
setActionHandler: (a, h) => void handlers.set(a, h),
|
||||||
|
setPositionState: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(navigator, "mediaSession", { value: session, configurable: true });
|
||||||
|
vi.stubGlobal(
|
||||||
|
"MediaMetadata",
|
||||||
|
class {
|
||||||
|
constructor(public init: MediaMetadataInit) {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const act = (name: string, d: Partial<MediaSessionActionDetails> = {}) =>
|
||||||
|
handlers.get(name)?.({ action: name as MediaSessionAction, ...d });
|
||||||
|
|
||||||
|
function controls(position = 100, duration = 3000): MediaSessionControls {
|
||||||
|
return {
|
||||||
|
play: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
seekTo: vi.fn(),
|
||||||
|
position: () => position,
|
||||||
|
duration: () => duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useMediaSession", () => {
|
||||||
|
it("tells the OS what is playing, with the picture as an absolute URL", () => {
|
||||||
|
const media = document.createElement("video");
|
||||||
|
renderHook(() =>
|
||||||
|
useMediaSession(media, { title: "Pilot", artist: "Westworld", artwork: "/api/plex/image/9" }),
|
||||||
|
);
|
||||||
|
const init = (session.metadata as unknown as { init: MediaMetadataInit }).init;
|
||||||
|
expect(init.title).toBe("Pilot");
|
||||||
|
expect(init.artist).toBe("Westworld");
|
||||||
|
expect(init.artwork?.[0]?.src).toBe(new URL("/api/plex/image/9", document.baseURI).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives the player through its own controls, skips clamped to the item", () => {
|
||||||
|
const media = document.createElement("video");
|
||||||
|
const c = controls(5, 3000);
|
||||||
|
renderHook(() => useMediaSession(media, { title: "T" }, c));
|
||||||
|
act("play");
|
||||||
|
act("pause");
|
||||||
|
expect(c.play).toHaveBeenCalledOnce();
|
||||||
|
expect(c.pause).toHaveBeenCalledOnce();
|
||||||
|
act("seekbackward");
|
||||||
|
expect(c.seekTo).toHaveBeenLastCalledWith(0);
|
||||||
|
act("seekforward", { seekOffset: 30 });
|
||||||
|
expect(c.seekTo).toHaveBeenLastCalledWith(35);
|
||||||
|
act("seekto", { seekTime: 1234 });
|
||||||
|
expect(c.seekTo).toHaveBeenLastCalledWith(1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchors the OS's progress bar to the player's own position, not the element's clock", () => {
|
||||||
|
const media = document.createElement("video");
|
||||||
|
renderHook(() => useMediaSession(media, { title: "T" }, controls(1800, 3000)));
|
||||||
|
media.dispatchEvent(new Event("seeked"));
|
||||||
|
expect(session.setPositionState).toHaveBeenLastCalledWith({
|
||||||
|
duration: 3000,
|
||||||
|
position: 1800,
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without controls of its own, plays and seeks the element", () => {
|
||||||
|
const media = document.createElement("video");
|
||||||
|
const play = vi.spyOn(media, "play").mockResolvedValue();
|
||||||
|
Object.defineProperty(media, "duration", { value: 600, configurable: true });
|
||||||
|
renderHook(() => useMediaSession(media, { title: "T" }));
|
||||||
|
act("play");
|
||||||
|
expect(play).toHaveBeenCalledOnce();
|
||||||
|
act("seekforward");
|
||||||
|
expect(media.currentTime).toBe(SESSION_SKIP_S);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the session on unmount — unless another player has taken it since", () => {
|
||||||
|
const a = document.createElement("video");
|
||||||
|
const first = renderHook(() => useMediaSession(a, { title: "A" }));
|
||||||
|
first.unmount();
|
||||||
|
expect(session.metadata).toBeNull();
|
||||||
|
expect(handlers.get("play")).toBeNull();
|
||||||
|
|
||||||
|
const one = renderHook(() => useMediaSession(a, { title: "A" }));
|
||||||
|
const two = renderHook(() => useMediaSession(document.createElement("video"), { title: "B" }));
|
||||||
|
one.unmount();
|
||||||
|
expect((session.metadata as unknown as { init: MediaMetadataInit }).init.title).toBe("B");
|
||||||
|
expect(handlers.get("play")).toBeTypeOf("function");
|
||||||
|
two.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without a title or an element", () => {
|
||||||
|
renderHook(() => useMediaSession(null, { title: "T" }));
|
||||||
|
renderHook(() => useMediaSession(document.createElement("video"), null));
|
||||||
|
expect(session.metadata).toBeNull();
|
||||||
|
expect(handlers.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// What is playing, told to the operating system through the Media Session API: the lock screen, the
|
||||||
|
// notification shade, a headset's buttons and the keyboard's media keys show the title and the
|
||||||
|
// picture and control the player — play, pause, skip back and forward, and a seek from the OS's own
|
||||||
|
// progress bar. One definition for the Plex, library and shared-link players.
|
||||||
|
//
|
||||||
|
// The YouTube player is not one of them, by measurement: the browser routes the OS controls to the
|
||||||
|
// frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title,
|
||||||
|
// channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown.
|
||||||
|
//
|
||||||
|
// It is also what lets a browser's own "background playback" keep the sound going when the page is
|
||||||
|
// backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element
|
||||||
|
// for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of
|
||||||
|
// the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which
|
||||||
|
// STOPPED playback instead of keeping it alive (measured on iPad Brave).
|
||||||
|
import { useEffect, useEffectEvent } from "react";
|
||||||
|
|
||||||
|
export interface MediaSessionInfo {
|
||||||
|
title: string;
|
||||||
|
/** The line under the title: a channel, a show, an uploader. */
|
||||||
|
artist?: string | null;
|
||||||
|
/** A picture of it (same-origin or absolute URL). */
|
||||||
|
artwork?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the OS's controls drive a player whose playhead is not simply its element's (a Plex
|
||||||
|
* transcode starts its stream at the resume point, and a seek outside it restarts the stream). */
|
||||||
|
export interface MediaSessionControls {
|
||||||
|
play(): void;
|
||||||
|
pause(): void;
|
||||||
|
seekTo(seconds: number): void;
|
||||||
|
/** Where it is, in the item's own seconds. */
|
||||||
|
position(): number;
|
||||||
|
/** How long the item is; 0 while unknown. */
|
||||||
|
duration(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OS's skip back / forward step when it does not say one. */
|
||||||
|
export const SESSION_SKIP_S = 10;
|
||||||
|
|
||||||
|
/** The controls of a plain media element: its own clock, its own seek. */
|
||||||
|
function elementControls(media: HTMLMediaElement): MediaSessionControls {
|
||||||
|
return {
|
||||||
|
play: () => void media.play().catch(() => {}),
|
||||||
|
pause: () => media.pause(),
|
||||||
|
seekTo: (s) => {
|
||||||
|
media.currentTime = s;
|
||||||
|
},
|
||||||
|
position: () => media.currentTime,
|
||||||
|
duration: () => (Number.isFinite(media.duration) ? media.duration : 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at
|
||||||
|
// the stated rate, so nothing is sent per frame.
|
||||||
|
const POSITION_EVENTS = [
|
||||||
|
"loadedmetadata",
|
||||||
|
"durationchange",
|
||||||
|
"play",
|
||||||
|
"pause",
|
||||||
|
"seeked",
|
||||||
|
"ratechange",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const;
|
||||||
|
|
||||||
|
// The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the
|
||||||
|
// React Compiler skip it (see CLAUDE.md's async-handler row).
|
||||||
|
|
||||||
|
/** The metadata, told to the OS — or null when this browser refuses it (then no session at all,
|
||||||
|
* rather than half of one). */
|
||||||
|
function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null {
|
||||||
|
try {
|
||||||
|
const metadata = new MediaMetadata(init);
|
||||||
|
session.metadata = metadata;
|
||||||
|
return metadata;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(
|
||||||
|
session: MediaSession,
|
||||||
|
action: MediaSessionAction,
|
||||||
|
handler: MediaSessionActionHandler | null,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
session.setActionHandler(action, handler);
|
||||||
|
} catch {
|
||||||
|
/* an action this browser does not know */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPosition(session: MediaSession, state?: MediaPositionState): void {
|
||||||
|
try {
|
||||||
|
session.setPositionState(state);
|
||||||
|
} catch {
|
||||||
|
/* a position the browser refuses (it checks position <= duration itself), or nothing to clear */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the
|
||||||
|
* element's own) is what its buttons do. Cleared on unmount, unless another player has already
|
||||||
|
* claimed the session since.
|
||||||
|
*/
|
||||||
|
export function useMediaSession(
|
||||||
|
media: HTMLMediaElement | null,
|
||||||
|
info: MediaSessionInfo | null | undefined,
|
||||||
|
controls?: MediaSessionControls,
|
||||||
|
): void {
|
||||||
|
const title = info?.title ?? null;
|
||||||
|
const artist = info?.artist ?? "";
|
||||||
|
const artwork = info?.artwork ?? null;
|
||||||
|
// Read when an action arrives, not bound at the effect: the Plex player's controls are fresh
|
||||||
|
// closures every render, and re-telling the OS on each render would be churn.
|
||||||
|
const ctl = useEffectEvent(
|
||||||
|
(): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined;
|
||||||
|
if (!session || !media || title == null || typeof MediaMetadata === "undefined") return;
|
||||||
|
const metadata = claim(session, {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [],
|
||||||
|
});
|
||||||
|
if (!metadata) return;
|
||||||
|
const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => {
|
||||||
|
const c = ctl();
|
||||||
|
if (!c) return;
|
||||||
|
const end = c.duration() || Infinity;
|
||||||
|
c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S))));
|
||||||
|
};
|
||||||
|
handle(session, "play", () => ctl()?.play());
|
||||||
|
handle(session, "pause", () => ctl()?.pause());
|
||||||
|
handle(session, "seekbackward", skip(-1));
|
||||||
|
handle(session, "seekforward", skip(1));
|
||||||
|
handle(session, "seekto", (d) => {
|
||||||
|
if (d.seekTime != null) ctl()?.seekTo(d.seekTime);
|
||||||
|
});
|
||||||
|
const anchor = () => {
|
||||||
|
const c = ctl();
|
||||||
|
const duration = c?.duration() ?? 0;
|
||||||
|
if (!c || !(duration > 0)) return;
|
||||||
|
setPosition(session, {
|
||||||
|
duration,
|
||||||
|
position: Math.min(duration, Math.max(0, c.position())),
|
||||||
|
playbackRate: media.playbackRate || 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (const type of POSITION_EVENTS) media.addEventListener(type, anchor);
|
||||||
|
anchor();
|
||||||
|
return () => {
|
||||||
|
for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor);
|
||||||
|
if (session.metadata !== metadata) return; // another player has the session now
|
||||||
|
session.metadata = null;
|
||||||
|
for (const action of ACTIONS) handle(session, action, null);
|
||||||
|
setPosition(session);
|
||||||
|
};
|
||||||
|
}, [media, title, artist, artwork]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 -----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user