web player: seekable transcode playback + custom controls

Transcoded videos stream through a live ffmpeg pipe with no byte ranges, so
the browser couldn't seek/rewind them at all. Fix it end to end:

- get_transcode accepts ?start=<secs> and passes -ss before -i (fast keyframe
  seek), rebasing timestamps to 0 from there.
- The player gets a custom control bar for transcode sources: play/pause, a
  scrubber driven by the known duration, current/total time, ±10s, mute +
  volume, and fullscreen, plus space / arrows / j / l / f keyboard shortcuts.
  Seeking re-requests the stream at the new offset; effective time is
  offset + element.currentTime. Every seek path (scrubber, chapters,
  transcript click, resume, save-position, chapter/transcript highlight) now
  routes through one offset-aware playerSeek()/effTime().
- Direct /files/ videos keep native <video controls> (already seekable).

Verified server-side: start=0 vs start=120 yield different streams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-13 06:47:40 -07:00
parent 033572d3bd
commit c3ff9121f6
2 changed files with 103 additions and 10 deletions

View file

@ -1746,9 +1746,18 @@ async fn get_sub_vtt(
([(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], vtt).into_response()
}
/// Query for [`get_transcode`]: `start` seconds to begin the stream at, so the
/// player can scrub a non-seekable live transcode by re-requesting at an offset.
#[derive(Deserialize)]
struct TranscodeQuery {
#[serde(default)]
start: Option<f64>,
}
async fn get_transcode(
State(state): State<Arc<WebState>>,
Path(id): Path<String>,
Query(q): Query<TranscodeQuery>,
) -> Response {
let path = {
let lib = state.library.lock_recover();
@ -1760,8 +1769,14 @@ async fn get_transcode(
let mut cmd = tokio::process::Command::new("ffmpeg");
cmd.arg("-hide_banner")
.arg("-loglevel").arg("error")
.arg("-i").arg(&path)
.arg("-loglevel").arg("error");
// Seek to the requested offset before the input (fast keyframe seek). The
// live pipe isn't byte-range seekable, so the player re-requests the stream
// at a new `start` to scrub; ffmpeg rebases timestamps to 0 from there.
if let Some(start) = q.start.filter(|s| *s > 0.0) {
cmd.arg("-ss").arg(format!("{start:.3}"));
}
cmd.arg("-i").arg(&path)
.arg("-c:v").arg("libx264")
.arg("-preset").arg("veryfast")
.arg("-crf").arg("23")