diff --git a/src/web.rs b/src/web.rs index 8ed9687..b2298fd 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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, +} + async fn get_transcode( State(state): State>, Path(id): Path, + Query(q): Query, ) -> 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") diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 70aa7b4..27e8769 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -84,6 +84,19 @@ .modal-hdr h2{flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .modal-body{display:flex;gap:12px;overflow:hidden;flex:1;min-height:0} .modal video{flex:1;min-width:0;max-height:75vh;background:#000} + /* Custom player (used for non-seekable transcode streams): the live ffmpeg + pipe has no byte ranges, so we drive a scrubber off the known duration and + re-request the stream at an offset on seek. */ + .vid-wrap{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px} + .vid-wrap video{flex:1;min-height:0;max-height:72vh;width:100%;background:#000} + .vctrl{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 8px;flex-wrap:wrap} + .vctrl .vbtn{background:transparent;border:none;color:var(--text);cursor:pointer;font-size:15px;line-height:1;padding:4px 7px;border-radius:4px} + .vctrl .vbtn:hover{background:var(--card)} + .vctrl .vseek{flex:1;min-width:120px;accent-color:var(--accent);cursor:pointer;height:5px} + .vctrl .vvol{width:70px;accent-color:var(--accent);cursor:pointer} + .vctrl .vtime{font-size:12px;color:var(--muted);font-family:monospace;white-space:nowrap} + .vid-wrap:fullscreen{background:#000;padding:0;gap:0} + .vid-wrap:fullscreen video{max-height:none} .chapters-pane{width:200px;background:var(--bg);border:1px solid var(--border);border-radius:4px;overflow-y:auto;flex-shrink:0} .chapters-pane h4{font-size:11px;padding:7px 10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg)} .chapter{padding:6px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)} @@ -1298,6 +1311,44 @@ function shufflePlay(){ } playVideo(pick.id); } +// Player state for the custom transcode controls. For direct /files/ videos +// `isTranscode` is false and native