fix: default to original audio, and pick tracks from the transport bar
YouTube marks an AI-dubbed audio track DEFAULT=YES on some videos and AVFoundation obeys the manifest, so a synthetic voice played over the original. The app now always serves its own rewritten master playlist — previously only when a quality cap was set — and marks the track whose language matches the video's own as the default, falling back to whichever track does not describe itself as dubbed. Every track is still listed, so switching remains possible. Adds an audio and subtitle picker to the transport bar, reading the video element's audioTracks and textTracks. Subtitles start off for the same reason the dub does: nothing translated is forced on. Also collapses the player's own title-bar strip in window fullscreen, which was leaving a stray bar above the player header.
This commit is contained in:
+266
-72
@@ -301,34 +301,44 @@ pub async fn resolve_stream(
|
|||||||
|
|
||||||
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
||||||
|
|
||||||
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
// One call gives both the HLS master playlist and the video's own language.
|
||||||
// so any one of them yields the master.
|
// Every m3u8 format shares the same manifest_url, so any one yields the master.
|
||||||
if let Some(master) = yt_dlp_print(
|
let lines = yt_dlp_lines(
|
||||||
&state,
|
&state,
|
||||||
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
&[
|
||||||
|
"-f",
|
||||||
|
"bv*[protocol^=m3u8]",
|
||||||
|
"--print",
|
||||||
|
"%(manifest_url)s",
|
||||||
|
"--print",
|
||||||
|
"%(language)s",
|
||||||
|
&url,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
let Some(cap) = max_height else {
|
if let Some(master) = lines.iter().find(|l| l.starts_with("http")).cloned() {
|
||||||
remember(&state, key, &master).await;
|
let lang = lines.iter().find(|l| !l.starts_with("http")).cloned();
|
||||||
return Ok(Stream { url: Some(master), playlist: None });
|
|
||||||
};
|
// Always serve our own copy, even with no height cap: YouTube marks an
|
||||||
// Fetch and cut it down. If anything about that fails, the adaptive
|
// AI-dubbed track as the manifest default on some videos, and that has
|
||||||
// master still plays — a quality preference is not worth an error.
|
// to be corrected whether or not the quality is capped.
|
||||||
return match state.http.get(&master).send().await {
|
match state.http.get(&master).send().await {
|
||||||
Ok(resp) => match resp.text().await {
|
Ok(resp) => match resp.text().await {
|
||||||
Ok(body) => Ok(match filter_master_playlist(&body, cap) {
|
Ok(body) => {
|
||||||
Some(filtered) => {
|
if let Some(rewritten) = rewrite_master(&body, max_height, lang.as_deref()) {
|
||||||
let served = state.playlists.publish(filtered).await;
|
let served = state.playlists.publish(rewritten).await;
|
||||||
remember(&state, key, &served).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(_) => {}
|
||||||
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
|
|
||||||
},
|
},
|
||||||
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.
|
// Rare fallback: an old-style progressive muxed MP4.
|
||||||
@@ -359,49 +369,162 @@ async fn remember(
|
|||||||
.insert(key, (url.to_string(), std::time::Instant::now()));
|
.insert(key, (url.to_string(), std::time::Instant::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps only the highest video variant at or below `max_height`, along with
|
/// Rewrites YouTube's HLS master playlist.
|
||||||
/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references).
|
|
||||||
///
|
///
|
||||||
/// Returns `None` if nothing matched, so the caller can fall back to adaptive
|
/// Two jobs. Optionally caps the video height. Always makes sure the *original*
|
||||||
/// rather than hand the player an empty playlist.
|
/// audio is the default: YouTube marks an AI-dubbed track `DEFAULT=YES` on some
|
||||||
pub fn filter_master_playlist(body: &str, max_height: u32) -> Option<String> {
|
/// 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<u32>,
|
||||||
|
original_lang: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
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)
|
// (height, stream-inf line, url line)
|
||||||
let mut variants: Vec<(u32, &str, &str)> = Vec::new();
|
let mut variants: Vec<(u32, &str, &str)> = Vec::new();
|
||||||
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
if line.starts_with("#EXT-X-MEDIA:") {
|
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:") {
|
} else if line.starts_with("#EXT-X-STREAM-INF:") {
|
||||||
let Some(url) = lines.get(i + 1) else { continue };
|
let Some(url) = lines.get(i + 1) else { continue };
|
||||||
if url.starts_with('#') || url.trim().is_empty() {
|
if url.starts_with('#') || url.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(h) = resolution_height(line) {
|
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
|
let preferred = preferred_audio(&audio, original_lang);
|
||||||
#EXT-X-INDEPENDENT-SEGMENTS
|
|
||||||
");
|
let mut out = String::from("#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n");
|
||||||
for m in media {
|
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_str(m);
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out.push_str(best.1);
|
for (_, inf, url) in chosen {
|
||||||
out.push('\n');
|
out.push_str(inf);
|
||||||
out.push_str(best.2);
|
out.push('\n');
|
||||||
out.push('\n');
|
out.push_str(url);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
Some(out)
|
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<usize> {
|
||||||
|
// 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<String> {
|
||||||
|
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.
|
/// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute.
|
||||||
fn resolution_height(stream_inf: &str) -> Option<u32> {
|
fn resolution_height(stream_inf: &str) -> Option<u32> {
|
||||||
let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len();
|
let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len();
|
||||||
@@ -410,6 +533,22 @@ fn resolution_height(stream_inf: &str) -> Option<u32> {
|
|||||||
value.split(&['x', 'X'][..]).nth(1)?.parse().ok()
|
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<String> {
|
||||||
|
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.
|
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
||||||
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
|
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
|
||||||
let mut cmd = state.yt_dlp();
|
let mut cmd = state.yt_dlp();
|
||||||
@@ -860,53 +999,108 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::filter_master_playlist;
|
use super::rewrite_master;
|
||||||
|
|
||||||
const MASTER: &str = concat!(
|
/// Two audio languages, with the dub marked default — the case that puts a
|
||||||
"#EXTM3U\n",
|
/// synthetic voice over the original.
|
||||||
"#EXT-X-INDEPENDENT-SEGMENTS\n",
|
const DUBBED: &str = concat!(
|
||||||
"#EXT-X-MEDIA:URI=\"https://a/audio.m3u8\",TYPE=AUDIO,GROUP-ID=\"234\",DEFAULT=YES\n",
|
"#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n",
|
||||||
"#EXT-X-STREAM-INF:BANDWIDTH=756324,CODECS=\"avc1,mp4a\",RESOLUTION=640x360,AUDIO=\"234\"\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",
|
"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",
|
"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",
|
"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]
|
#[test]
|
||||||
fn keeps_the_best_variant_at_or_below_the_cap() {
|
fn the_original_language_becomes_the_default_track() {
|
||||||
let out = filter_master_playlist(MASTER, 720).unwrap();
|
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/720.m3u8"));
|
||||||
assert!(!out.contains("https://a/1080.m3u8"));
|
assert!(!out.contains("https://a/1080.m3u8"));
|
||||||
assert!(!out.contains("https://a/360.m3u8"));
|
assert!(!out.contains("https://a/360.m3u8"));
|
||||||
}
|
// The audio fix still applies when capping.
|
||||||
|
assert!(audio_line(&out, "en").contains("DEFAULT=YES"));
|
||||||
#[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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() {
|
fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() {
|
||||||
assert!(filter_master_playlist(MASTER, 144).is_none());
|
assert!(rewrite_master(DUBBED, Some(144), Some("en")).is_none());
|
||||||
assert!(filter_master_playlist("#EXTM3U\n", 1080).is_none());
|
assert!(rewrite_master("#EXTM3U\n", Some(1080), None).is_none());
|
||||||
assert!(filter_master_playlist("", 1080).is_none());
|
assert!(rewrite_master("", None, None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stream_inf_with_no_following_url_is_skipped() {
|
fn a_single_audio_track_is_left_as_the_default() {
|
||||||
let truncated = "#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n";
|
let single = concat!(
|
||||||
assert!(filter_master_playlist(truncated, 1080).is_none());
|
"#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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export default function App() {
|
|||||||
index={playingIndex}
|
index={playingIndex}
|
||||||
total={items.length}
|
total={items.length}
|
||||||
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
||||||
|
titleBarInset={titleBarInset}
|
||||||
onPrev={
|
onPrev={
|
||||||
stepFrom(playingIndex, -1) != null
|
stepFrom(playingIndex, -1) != null
|
||||||
? () => setPlayingIndex(stepFrom(playingIndex, -1))
|
? () => setPlayingIndex(stepFrom(playingIndex, -1))
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface Props {
|
|||||||
/** Position in the current feed, for the "3 of 180" readout. */
|
/** Position in the current feed, for the "3 of 180" readout. */
|
||||||
index: number;
|
index: number;
|
||||||
total: 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({
|
export default function Player({
|
||||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||||
maxHeight, index, total,
|
maxHeight, index, total, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const streaming = path === null;
|
const streaming = path === null;
|
||||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||||
@@ -208,7 +210,7 @@ export default function Player({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
{titleBarInset && <div data-tauri-drag-region className="h-9 shrink-0" />}
|
||||||
<header
|
<header
|
||||||
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
||||||
dark:border-slate-800 dark:bg-slate-900"
|
dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */
|
||||||
|
interface AudioTrackLike {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
language: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
interface AudioTrackListLike {
|
||||||
|
length: number;
|
||||||
|
[index: number]: AudioTrackLike;
|
||||||
|
addEventListener?: (t: string, fn: () => 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 {
|
interface Props {
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
@@ -39,6 +67,78 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [pip, setPip] = useState(false);
|
const [pip, setPip] = useState(false);
|
||||||
const [full, setFull] = useState(false);
|
const [full, setFull] = useState(false);
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const [audio, setAudio] = useState<AudioTrackLike[]>([]);
|
||||||
|
const [subs, setSubs] = useState<TextTrack[]>([]);
|
||||||
|
const [, bump] = useState(0);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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 —
|
// Mirror the element's state rather than assuming ours is authoritative —
|
||||||
// playback can change from the keyboard, the system, or the video ending.
|
// 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) && (
|
||||||
|
<div ref={menuRef} className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
|
||||||
|
className={btn}
|
||||||
|
title="Audio and subtitles"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
<path strokeLinecap="round" d="M7 15h4M14 15h3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menu && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
|
||||||
|
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
{audio.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Audio
|
||||||
|
</div>
|
||||||
|
{audio.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.id || `${t.language}-${i}`}
|
||||||
|
onClick={() => chooseAudio(i)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
||||||
|
text-[12px] cursor-pointer hover:bg-slate-800 ${
|
||||||
|
t.enabled ? "text-white" : "text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-3 shrink-0 text-sky-400">{t.enabled ? "✓" : ""}</span>
|
||||||
|
<span className="truncate">{t.label || t.language || `Track ${i + 1}`}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Subtitles
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => chooseSub(null)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
||||||
|
text-[12px] cursor-pointer hover:bg-slate-800 ${
|
||||||
|
subs.every((t) => t.mode !== "showing")
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-3 shrink-0 text-sky-400">
|
||||||
|
{subs.every((t) => t.mode !== "showing") ? "✓" : ""}
|
||||||
|
</span>
|
||||||
|
Off
|
||||||
|
</button>
|
||||||
|
{subs.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.id || `${t.language}-${i}`}
|
||||||
|
onClick={() => chooseSub(t)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
||||||
|
text-[12px] cursor-pointer hover:bg-slate-800 ${
|
||||||
|
t.mode === "showing" ? "text-white" : "text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-3 shrink-0 text-sky-400">
|
||||||
|
{t.mode === "showing" ? "✓" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{t.label || t.language || `Subtitles ${i + 1}`}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
|
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
|
||||||
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
|||||||
Reference in New Issue
Block a user