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:
@@ -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) == []
|
||||
Reference in New Issue
Block a user