fix(player): give YouTube's own fullscreen a working path + round-6 findings
The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).
So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.
Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
(was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
This commit is contained in:
+50
-36
@@ -273,15 +273,14 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
|
||||
|
||||
def _make_progress_hook(job_id: int, asset_id: int):
|
||||
state = {"last_db": 0.0, "last_check": 0.0, "meta_done": False}
|
||||
state = {"meta_done": False}
|
||||
cancelled = _cancel_poll(job_id) # same policy as the ffmpeg path
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S)
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
now = time.monotonic()
|
||||
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||
if now - state["last_check"] >= 2.0:
|
||||
state["last_check"] = now
|
||||
if _job_status(job_id) not in ("running", None):
|
||||
raise _Aborted
|
||||
if cancelled():
|
||||
raise _Aborted
|
||||
if not state["meta_done"] and d.get("info_dict"):
|
||||
state["meta_done"] = True
|
||||
try:
|
||||
@@ -290,9 +289,8 @@ def _make_progress_hook(job_id: int, asset_id: int):
|
||||
pass
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
if now - state["last_db"] < 1.0:
|
||||
if not allow_write():
|
||||
return
|
||||
state["last_db"] = now
|
||||
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||
# Label the phase so the user understands the bar resetting between streams instead of
|
||||
# seeing a mysterious 95%→5% jump.
|
||||
@@ -319,46 +317,62 @@ def _make_progress_hook(job_id: int, asset_id: int):
|
||||
return hook
|
||||
|
||||
|
||||
# ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s, and run_ffmpeg calls both of
|
||||
# its callbacks once per LINE. Each of these is a database round-trip (`_job_status` opens a session
|
||||
# and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so an unthrottled pair costs ~10^5 queries
|
||||
# on a long re-encode. Both are therefore rate-limited to ~1s — the same 1s DB throttle the
|
||||
# download progress hook already applies (see `_make_progress_hook`).
|
||||
_FFMPEG_CALLBACK_INTERVAL_S = 1.0
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
# opens a session and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so unthrottled they cost
|
||||
# ~10^5 queries on a long job. ONE interval, ONE implementation, used by both paths — three
|
||||
# hand-rolled copies is how the ffmpeg side ended up unthrottled while the download side was fine.
|
||||
_DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str):
|
||||
"""`on_progress` for run_ffmpeg: persist at most one row write per second, and only when the
|
||||
whole-percent value actually changed (the bar shows integers)."""
|
||||
state = {"at": 0.0, "pct": -1}
|
||||
def _rate_limiter(interval_s: float, *, now=time.monotonic):
|
||||
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
|
||||
`now` is injectable so tests drive a clock instead of patching the global one."""
|
||||
state = {"at": 0.0}
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
now = time.monotonic()
|
||||
if whole == state["pct"] or now - state["at"] < _FFMPEG_CALLBACK_INTERVAL_S:
|
||||
return
|
||||
state["at"] = now
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
def should_run() -> bool:
|
||||
t = now()
|
||||
if state["at"] and t - state["at"] < interval_s:
|
||||
return False
|
||||
state["at"] = t
|
||||
return True
|
||||
|
||||
return write
|
||||
return should_run
|
||||
|
||||
|
||||
def _ffmpeg_cancel_poll(job_id: int):
|
||||
"""`should_cancel` for run_ffmpeg: ask the DB at most once a second and reuse the answer in
|
||||
between. Cancellation is sticky, so once it says yes it never queries again."""
|
||||
state = {"at": 0.0, "cancelled": False}
|
||||
def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, now=time.monotonic):
|
||||
"""Cooperative "has this job been paused/cancelled?" poll, rate-limited and sticky: once it
|
||||
says yes it never queries again. Shared by the yt-dlp hook and run_ffmpeg so cancel latency is
|
||||
one policy, not one per call site."""
|
||||
allow = _rate_limiter(interval_s, now=now)
|
||||
state = {"cancelled": False}
|
||||
|
||||
def cancelled() -> bool:
|
||||
now = time.monotonic()
|
||||
if not state["cancelled"] and now - state["at"] >= _FFMPEG_CALLBACK_INTERVAL_S:
|
||||
state["at"] = now
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
||||
"""`on_progress` for run_ffmpeg: persist at most one row write per interval, and only when the
|
||||
whole-percent value actually changed (the bar shows integers)."""
|
||||
allow = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||
state = {"pct": -1}
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
if whole == state["pct"] or not allow():
|
||||
return
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _pp_phase(pp: str) -> str:
|
||||
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||
@@ -533,7 +547,7 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
should_cancel=_ffmpeg_cancel_poll(job_id),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
@@ -712,7 +726,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_ffmpeg_cancel_poll(job_id),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
@@ -26,8 +26,7 @@ class TestProgressWriter:
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(worker.time, "monotonic", lambda: clock["t"])
|
||||
write = worker._ffmpeg_progress_writer(7, "processing")
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10)
|
||||
write(11) # same second → dropped
|
||||
clock["t"] += 1.5
|
||||
@@ -38,8 +37,7 @@ class TestProgressWriter:
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(worker.time, "monotonic", lambda: clock["t"])
|
||||
write = worker._ffmpeg_progress_writer(7, "processing")
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10.1)
|
||||
clock["t"] += 5
|
||||
write(10.9) # still 10% — the bar shows integers
|
||||
@@ -50,7 +48,7 @@ class TestCancelPoll:
|
||||
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||
cancelled = worker._ffmpeg_cancel_poll(7)
|
||||
cancelled = worker._cancel_poll(7)
|
||||
for _ in range(40):
|
||||
assert cancelled() is False
|
||||
assert len(calls) == 1
|
||||
@@ -64,8 +62,7 @@ class TestCancelPoll:
|
||||
return "cancelled"
|
||||
|
||||
monkeypatch.setattr(worker, "_job_status", status)
|
||||
monkeypatch.setattr(worker.time, "monotonic", lambda: clock["t"])
|
||||
cancelled = worker._ffmpeg_cancel_poll(7)
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is True
|
||||
clock["t"] += 60 # cancellation is sticky: no re-query, and no un-cancelling
|
||||
assert cancelled() is True
|
||||
@@ -75,8 +72,7 @@ class TestCancelPoll:
|
||||
clock = {"t": 1000.0}
|
||||
state = {"status": "running"}
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: state["status"])
|
||||
monkeypatch.setattr(worker.time, "monotonic", lambda: clock["t"])
|
||||
cancelled = worker._ffmpeg_cancel_poll(7)
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is False
|
||||
state["status"] = "paused"
|
||||
clock["t"] += 1.0 # the throttle must not delay a cancel by more than its interval
|
||||
|
||||
Reference in New Issue
Block a user