diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9290308..b4b08ec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -301,34 +301,44 @@ pub async fn resolve_stream( let url = format!("https://www.youtube.com/watch?v={video_id}"); - // The HLS master playlist. Every m3u8 format shares the same manifest_url, - // so any one of them yields the master. - if let Some(master) = yt_dlp_print( + // One call gives both the HLS master playlist and the video's own language. + // Every m3u8 format shares the same manifest_url, so any one yields the master. + let lines = yt_dlp_lines( &state, - &["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url], + &[ + "-f", + "bv*[protocol^=m3u8]", + "--print", + "%(manifest_url)s", + "--print", + "%(language)s", + &url, + ], ) - .await - { - let Some(cap) = max_height else { - remember(&state, key, &master).await; - return Ok(Stream { url: Some(master), playlist: None }); - }; - // Fetch and cut it down. If anything about that fails, the adaptive - // master still plays — a quality preference is not worth an error. - return match state.http.get(&master).send().await { + .await; + + if let Some(master) = lines.iter().find(|l| l.starts_with("http")).cloned() { + let lang = lines.iter().find(|l| !l.starts_with("http")).cloned(); + + // Always serve our own copy, even with no height cap: YouTube marks an + // AI-dubbed track as the manifest default on some videos, and that has + // to be corrected whether or not the quality is capped. + match state.http.get(&master).send().await { Ok(resp) => match resp.text().await { - Ok(body) => Ok(match filter_master_playlist(&body, cap) { - Some(filtered) => { - let served = state.playlists.publish(filtered).await; + Ok(body) => { + if let Some(rewritten) = rewrite_master(&body, max_height, lang.as_deref()) { + let served = state.playlists.publish(rewritten).await; remember(&state, key, &served).await; - Stream { url: Some(served), playlist: None } + return Ok(Stream { url: Some(served), playlist: None }); } - None => Stream { url: Some(master), playlist: None }, - }), - Err(_) => Ok(Stream { url: Some(master), playlist: None }), + } + Err(_) => {} }, - Err(_) => Ok(Stream { url: Some(master), playlist: None }), - }; + Err(_) => {} + } + // Anything unexpected about the manifest: fall back to YouTube's own. + remember(&state, key, &master).await; + return Ok(Stream { url: Some(master), playlist: None }); } // Rare fallback: an old-style progressive muxed MP4. @@ -359,49 +369,162 @@ async fn remember( .insert(key, (url.to_string(), std::time::Instant::now())); } -/// Keeps only the highest video variant at or below `max_height`, along with -/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references). +/// Rewrites YouTube's HLS master playlist. /// -/// Returns `None` if nothing matched, so the caller can fall back to adaptive -/// rather than hand the player an empty playlist. -pub fn filter_master_playlist(body: &str, max_height: u32) -> Option { +/// Two jobs. Optionally caps the video height. Always makes sure the *original* +/// audio is the default: YouTube marks an AI-dubbed track `DEFAULT=YES` on some +/// videos, and AVFoundation obeys the manifest, so you get a synthetic voice +/// over the original. Every audio group is still listed, so a player — or our +/// own track picker — can switch. +/// +/// Returns `None` only when a height cap matched nothing, so the caller can +/// fall back to YouTube's own manifest rather than serve an empty playlist. +pub fn rewrite_master( + body: &str, + max_height: Option, + original_lang: Option<&str>, +) -> Option { let lines: Vec<&str> = body.lines().collect(); - let mut media = Vec::new(); + let mut audio: Vec<&str> = Vec::new(); + let mut other_media: Vec<&str> = Vec::new(); // (height, stream-inf line, url line) let mut variants: Vec<(u32, &str, &str)> = Vec::new(); for (i, line) in lines.iter().enumerate() { if line.starts_with("#EXT-X-MEDIA:") { - media.push(*line); + if attr(line, "TYPE").as_deref() == Some("AUDIO") { + audio.push(line); + } else { + other_media.push(line); + } } else if line.starts_with("#EXT-X-STREAM-INF:") { let Some(url) = lines.get(i + 1) else { continue }; if url.starts_with('#') || url.trim().is_empty() { continue; } if let Some(h) = resolution_height(line) { - if h <= max_height { - variants.push((h, *line, *url)); - } + variants.push((h, *line, *url)); } } } - let best = variants.iter().max_by_key(|(h, _, _)| *h)?; + let chosen: Vec<(u32, &str, &str)> = match max_height { + Some(cap) => { + let best = variants.iter().filter(|(h, _, _)| *h <= cap).max_by_key(|(h, _, _)| *h)?; + vec![*best] + } + // No cap: keep every variant so the player can still adapt. + None => variants, + }; + if chosen.is_empty() { + return None; + } - let mut out = String::from("#EXTM3U -#EXT-X-INDEPENDENT-SEGMENTS -"); - for m in media { + let preferred = preferred_audio(&audio, original_lang); + + let mut out = String::from("#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n"); + for (i, line) in audio.iter().enumerate() { + out.push_str(&set_default(line, Some(i) == preferred)); + out.push('\n'); + } + for m in other_media { out.push_str(m); out.push('\n'); } - out.push_str(best.1); - out.push('\n'); - out.push_str(best.2); - out.push('\n'); + for (_, inf, url) in chosen { + out.push_str(inf); + out.push('\n'); + out.push_str(url); + out.push('\n'); + } Some(out) } +/// Index of the audio group that should be the default. +/// +/// The video's own language wins. Failing that, anything not describing itself +/// as dubbed. A manifest with one audio group needs no opinion. +fn preferred_audio(audio: &[&str], original_lang: Option<&str>) -> Option { + // With one track there is nothing to choose; it stays the default. + if audio.len() < 2 { + return (!audio.is_empty()).then_some(0); + } + if let Some(lang) = original_lang.filter(|l| !l.is_empty() && *l != "NA") { + let base = lang.split('-').next().unwrap_or(lang).to_ascii_lowercase(); + if let Some(i) = audio.iter().position(|l| { + attr(l, "LANGUAGE") + .map(|v| v.to_ascii_lowercase().starts_with(&base)) + .unwrap_or(false) + }) { + return Some(i); + } + } + audio + .iter() + .position(|l| { + let name = attr(l, "NAME").unwrap_or_default().to_ascii_lowercase(); + !name.contains("dub") && !name.contains("auto") + }) + .or(Some(0)) +} + +/// Rewrites the DEFAULT/AUTOSELECT flags on one EXT-X-MEDIA line. +fn set_default(line: &str, is_default: bool) -> String { + let want = if is_default { "YES" } else { "NO" }; + let mut out = String::with_capacity(line.len() + 32); + let mut rest = line; + // Attributes are comma separated but URIs contain commas inside quotes, so + // only rewrite the two flags by name and leave the rest of the line intact. + for key in ["DEFAULT", "AUTOSELECT"] { + let needle = format!(",{key}="); + if let Some(at) = rest.find(&needle) { + let value_start = at + needle.len(); + let value_end = rest[value_start..] + .find(',') + .map(|o| value_start + o) + .unwrap_or(rest.len()); + out.clear(); + out.push_str(&rest[..value_start]); + out.push_str(want); + out.push_str(&rest[value_end..]); + rest = Box::leak(out.clone().into_boxed_str()); + } + } + let mut s = rest.trim_end().to_string(); + for key in ["DEFAULT", "AUTOSELECT"] { + if !s.contains(&format!(",{key}=")) { + s.push_str(&format!(",{key}={want}")); + } + } + s +} + +/// Reads one attribute from an EXT-X tag line. +fn attr(line: &str, key: &str) -> Option { + let needle = format!("{key}="); + let mut from = 0; + while let Some(at) = line[from..].find(&needle) { + let abs = from + at; + // Must be preceded by ':' or ',' so LANGUAGE does not match INDEX-LANGUAGE. + let ok = abs == 0 || matches!(line.as_bytes()[abs - 1], b',' | b':'); + let start = abs + needle.len(); + if ok { + let bytes = line.as_bytes(); + if bytes.get(start) == Some(&b'"') { + let end = line[start + 1..].find('"')? + start + 1; + return Some(line[start + 1..end].to_string()); + } + let end = line[start..] + .find(',') + .map(|o| start + o) + .unwrap_or(line.len()); + return Some(line[start..end].trim().to_string()); + } + from = start; + } + None +} + /// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute. fn resolution_height(stream_inf: &str) -> Option { let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len(); @@ -410,6 +533,22 @@ fn resolution_height(stream_inf: &str) -> Option { value.split(&['x', 'X'][..]).nth(1)?.parse().ok() } +/// Runs yt-dlp and returns every non-empty stdout line. +async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec { + let mut cmd = state.yt_dlp(); + cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); + cmd.args(args); + let Ok(out) = cmd.output().await else { return Vec::new() }; + if !out.status.success() { + return Vec::new(); + } + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + /// Runs yt-dlp and returns its first non-empty stdout line, or None. async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option { let mut cmd = state.yt_dlp(); @@ -860,53 +999,108 @@ pub fn build_state(app: &AppHandle) -> Result { #[cfg(test)] mod tests { - use super::filter_master_playlist; + use super::rewrite_master; - const MASTER: &str = concat!( - "#EXTM3U\n", - "#EXT-X-INDEPENDENT-SEGMENTS\n", - "#EXT-X-MEDIA:URI=\"https://a/audio.m3u8\",TYPE=AUDIO,GROUP-ID=\"234\",DEFAULT=YES\n", - "#EXT-X-STREAM-INF:BANDWIDTH=756324,CODECS=\"avc1,mp4a\",RESOLUTION=640x360,AUDIO=\"234\"\n", + /// Two audio languages, with the dub marked default — the case that puts a + /// synthetic voice over the original. + const DUBBED: &str = concat!( + "#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n", + "#EXT-X-MEDIA:URI=\"https://a/en.m3u8\",TYPE=AUDIO,GROUP-ID=\"en\",LANGUAGE=\"en\",NAME=\"English original\",DEFAULT=NO,AUTOSELECT=NO\n", + "#EXT-X-MEDIA:URI=\"https://a/nl.m3u8\",TYPE=AUDIO,GROUP-ID=\"nl\",LANGUAGE=\"nl\",NAME=\"Nederlands (dubbed)\",DEFAULT=YES,AUTOSELECT=YES\n", + "#EXT-X-MEDIA:URI=\"https://a/sub.m3u8\",TYPE=SUBTITLES,GROUP-ID=\"vtt\",LANGUAGE=\"en\",NAME=\"English\",DEFAULT=NO,AUTOSELECT=YES\n", + "#EXT-X-STREAM-INF:BANDWIDTH=756324,RESOLUTION=640x360,AUDIO=\"en\"\n", "https://a/360.m3u8\n", - "#EXT-X-STREAM-INF:BANDWIDTH=3878958,CODECS=\"avc1,mp4a\",RESOLUTION=1280x720,AUDIO=\"234\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=3878958,RESOLUTION=1280x720,AUDIO=\"en\"\n", "https://a/720.m3u8\n", - "#EXT-X-STREAM-INF:BANDWIDTH=6039686,CODECS=\"avc1,mp4a\",RESOLUTION=1920x1080,AUDIO=\"234\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=6039686,RESOLUTION=1920x1080,AUDIO=\"en\"\n", "https://a/1080.m3u8\n", ); + fn audio_line(out: &str, lang: &str) -> String { + out.lines() + .find(|l| l.contains("TYPE=AUDIO") && l.contains(&format!("LANGUAGE=\"{lang}\""))) + .unwrap_or("") + .to_string() + } + #[test] - fn keeps_the_best_variant_at_or_below_the_cap() { - let out = filter_master_playlist(MASTER, 720).unwrap(); + fn the_original_language_becomes_the_default_track() { + let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); + assert!(audio_line(&out, "en").contains("DEFAULT=YES")); + assert!(audio_line(&out, "en").contains("AUTOSELECT=YES")); + assert!(audio_line(&out, "nl").contains("DEFAULT=NO")); + assert!(audio_line(&out, "nl").contains("AUTOSELECT=NO")); + } + + #[test] + fn every_audio_track_is_still_offered() { + // Demoting a dub must not remove it; the player still has to be able + // to switch to it. + let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); + assert!(out.contains("https://a/en.m3u8")); + assert!(out.contains("https://a/nl.m3u8")); + } + + #[test] + fn subtitles_survive_untouched() { + let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); + assert!(out.contains("TYPE=SUBTITLES")); + assert!(out.contains("https://a/sub.m3u8")); + } + + #[test] + fn without_a_language_it_avoids_anything_calling_itself_dubbed() { + let out = rewrite_master(DUBBED, None, None).unwrap(); + assert!(audio_line(&out, "en").contains("DEFAULT=YES")); + assert!(audio_line(&out, "nl").contains("DEFAULT=NO")); + } + + #[test] + fn an_unknown_language_still_picks_a_sane_default() { + // A language we cannot match must not leave every track demoted. + let out = rewrite_master(DUBBED, None, Some("zz")).unwrap(); + let defaults = out + .lines() + .filter(|l| l.contains("TYPE=AUDIO") && l.contains("DEFAULT=YES")) + .count(); + assert_eq!(defaults, 1); + } + + #[test] + fn no_cap_keeps_every_variant_so_the_player_can_adapt() { + let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); + for u in ["https://a/360.m3u8", "https://a/720.m3u8", "https://a/1080.m3u8"] { + assert!(out.contains(u), "missing {u}"); + } + } + + #[test] + fn a_cap_keeps_the_best_variant_at_or_below_it() { + let out = rewrite_master(DUBBED, Some(720), Some("en")).unwrap(); assert!(out.contains("https://a/720.m3u8")); assert!(!out.contains("https://a/1080.m3u8")); assert!(!out.contains("https://a/360.m3u8")); - } - - #[test] - fn always_carries_the_audio_group_across() { - // A video-only variant would play silently, so EXT-X-MEDIA must survive. - let out = filter_master_playlist(MASTER, 360).unwrap(); - assert!(out.contains("TYPE=AUDIO")); - assert!(out.contains("https://a/audio.m3u8")); - assert!(out.starts_with("#EXTM3U")); - } - - #[test] - fn an_exact_match_is_included_not_excluded() { - let out = filter_master_playlist(MASTER, 1080).unwrap(); - assert!(out.contains("https://a/1080.m3u8")); + // The audio fix still applies when capping. + assert!(audio_line(&out, "en").contains("DEFAULT=YES")); } #[test] fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() { - assert!(filter_master_playlist(MASTER, 144).is_none()); - assert!(filter_master_playlist("#EXTM3U\n", 1080).is_none()); - assert!(filter_master_playlist("", 1080).is_none()); + assert!(rewrite_master(DUBBED, Some(144), Some("en")).is_none()); + assert!(rewrite_master("#EXTM3U\n", Some(1080), None).is_none()); + assert!(rewrite_master("", None, None).is_none()); } #[test] - fn a_stream_inf_with_no_following_url_is_skipped() { - let truncated = "#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n"; - assert!(filter_master_playlist(truncated, 1080).is_none()); + fn a_single_audio_track_is_left_as_the_default() { + let single = concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:URI=\"https://a/a.m3u8\",TYPE=AUDIO,GROUP-ID=\"1\",NAME=\"Default\",DEFAULT=YES,AUTOSELECT=YES\n", + "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=640x360,AUDIO=\"1\"\n", + "https://a/360.m3u8\n", + ); + let out = rewrite_master(single, None, None).unwrap(); + assert!(out.contains("TYPE=AUDIO")); + assert!(out.contains("DEFAULT=YES")); } } diff --git a/src/App.tsx b/src/App.tsx index f0eb35f..0c8bcf7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -339,6 +339,7 @@ export default function App() { index={playingIndex} total={items.length} maxHeight={streamQuality === "best" ? null : Number(streamQuality)} + titleBarInset={titleBarInset} onPrev={ stepFrom(playingIndex, -1) != null ? () => setPlayingIndex(stepFrom(playingIndex, -1)) diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 51e6ec5..1e12d93 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -21,6 +21,8 @@ interface Props { /** Position in the current feed, for the "3 of 180" readout. */ index: number; total: number; + /** False in window fullscreen, where there are no traffic lights to clear. */ + titleBarInset: boolean; } /** @@ -95,7 +97,7 @@ const RESUME_EDGE_S = 5; */ export default function Player({ item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, - maxHeight, index, total, + maxHeight, index, total, titleBarInset, }: Props) { const streaming = path === null; const [src, setSrc] = useState(path ? fileUrl(path) : null); @@ -208,7 +210,7 @@ export default function Player({ return (
-
+ {titleBarInset &&
}
void) => void; + removeEventListener?: (t: string, fn: () => void) => void; +} +type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike }; + +function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] { + const list = (v as VideoWithTracks | null)?.audioTracks; + if (!list) return []; + return Array.from({ length: list.length }, (_, i) => list[i]); +} + +function listSubs(v: HTMLVideoElement | null): TextTrack[] { + if (!v) return []; + return Array.from(v.textTracks).filter( + (t) => t.kind === "subtitles" || t.kind === "captions", + ); +} interface Props { videoRef: React.RefObject; @@ -39,6 +67,78 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props const [muted, setMuted] = useState(false); const [pip, setPip] = useState(false); const [full, setFull] = useState(false); + const [menu, setMenu] = useState(false); + const [audio, setAudio] = useState([]); + const [subs, setSubs] = useState([]); + const [, bump] = useState(0); + const menuRef = useRef(null); + + // Tracks arrive with the manifest, after metadata rather than on mount. + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const read = () => { + setAudio(listAudio(v)); + setSubs(listSubs(v)); + }; + read(); + + // Nothing translated gets forced on. YouTube marks its subtitle track + // AUTOSELECT=YES and WebKit will switch it on when it matches the system + // language; that is the same unwanted auto-selection as a dubbed audio + // track, so subtitles start off and stay a deliberate choice. + const silenceSubs = () => { + for (const t of Array.from(v.textTracks)) t.mode = "disabled"; + read(); + }; + v.addEventListener("loadedmetadata", silenceSubs); + + v.addEventListener("loadedmetadata", read); + const at = (v as VideoWithTracks).audioTracks; + at?.addEventListener?.("addtrack", read); + v.textTracks.addEventListener?.("addtrack", read); + // Manifests can take a moment to surface renditions. + const id = setInterval(read, 1000); + const stop = setTimeout(() => clearInterval(id), 8000); + return () => { + v.removeEventListener("loadedmetadata", silenceSubs); + v.removeEventListener("loadedmetadata", read); + at?.removeEventListener?.("addtrack", read); + v.textTracks.removeEventListener?.("addtrack", read); + clearInterval(id); + clearTimeout(stop); + }; + }, [videoRef]); + + // Close the menu on any outside click. + useEffect(() => { + if (!menu) return; + const onDown = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenu(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [menu]); + + const chooseAudio = (i: number) => { + const v = videoRef.current; + const list = (v as VideoWithTracks | null)?.audioTracks; + if (!list) return; + // Exactly one enabled, or WebKit mixes them. + for (let k = 0; k < list.length; k++) list[k].enabled = k === i; + bump((n) => n + 1); + onActivity?.(); + }; + + const chooseSub = (track: TextTrack | null) => { + const v = videoRef.current; + if (!v) return; + for (const t of Array.from(v.textTracks)) { + t.mode = t === track ? "showing" : "disabled"; + } + bump((n) => n + 1); + onActivity?.(); + }; // Mirror the element's state rather than assuming ours is authoritative — // playback can change from the keyboard, the system, or the video ending. @@ -212,6 +312,86 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props }} /> + {(audio.length > 1 || subs.length > 0) && ( +
+ + + {menu && ( +
+ {audio.length > 1 && ( + <> +
+ Audio +
+ {audio.map((t, i) => ( + + ))} + + )} + + {subs.length > 0 && ( + <> +
+ Subtitles +
+ + {subs.map((t, i) => ( + + ))} + + )} +
+ )} +
+ )} +