feat: uniform control height, chrome auto-hide, streaming quality

Every control in the app now shares one height (CONTROL_H), with width
still growing to fit its label. The player transport is larger, Back is
an icon, and the footer buttons match the prev/next size.

Player chrome — transport and edge arrows — fades after 2.6s of
inactivity and never hides while paused.

The title-bar strip collapses in macOS window fullscreen, where the
traffic lights are gone and it was just a blank white bar.

Streaming quality is now selectable alongside download quality. A cap is
applied by rewriting YouTube's HLS master playlist down to the best
variant at or below the chosen height, keeping its audio group. That
playlist is served from a small loopback HTTP server: Safari's native
HLS is backed by AVFoundation, which cannot read blob: or custom-scheme
URLs, so a Blob URL silently fails to play.

Settings closes with an icon button and its selects match the shared
control height.
This commit is contained in:
vincent
2026-08-29 11:28:05 +02:00
parent 211823f265
commit 9bb7b71225
16 changed files with 503 additions and 92 deletions
+142 -4
View File
@@ -7,6 +7,7 @@ use crate::models::{
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
};
use crate::net;
use crate::playlist_server::PlaylistServer;
use crate::takeout;
use crate::thumbs;
@@ -35,6 +36,7 @@ pub struct AppState {
pub app_data: PathBuf,
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
pub download_slots: Arc<Semaphore>,
pub playlists: PlaylistServer,
}
#[derive(Clone, Serialize)]
@@ -174,18 +176,49 @@ pub async fn import_takeout_csv(
/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
/// adaptive bitrate for free.
#[derive(Serialize)]
pub struct Stream {
/// Direct URL to hand the player, when no filtering was needed.
pub url: Option<String>,
/// A rewritten HLS master playlist, when a quality cap was applied. The
/// frontend turns this into a Blob URL — every URL inside is absolute, so
/// the playlist works from anywhere.
pub playlist: Option<String>,
}
#[tauri::command]
pub async fn resolve_stream(video_id: String) -> Result<String, String> {
pub async fn resolve_stream(
video_id: String,
max_height: Option<u32>,
state: State<'_, AppState>,
) -> Result<Stream, String> {
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(u) = yt_dlp_print(
if let Some(master) = yt_dlp_print(
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
)
.await
{
return Ok(u);
let Some(cap) = max_height else {
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 {
Ok(resp) => match resp.text().await {
Ok(body) => Ok(match filter_master_playlist(&body, cap) {
Some(filtered) => Stream {
url: Some(state.playlists.publish(filtered).await),
playlist: None,
},
None => Stream { url: Some(master), playlist: None },
}),
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
},
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
};
}
// Rare fallback: an old-style progressive muxed MP4.
@@ -198,12 +231,63 @@ pub async fn resolve_stream(video_id: String) -> Result<String, String> {
])
.await
{
return Ok(u);
return Ok(Stream { url: Some(u), playlist: None });
}
Err("Could not find a playable stream for this video.".into())
}
/// 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).
///
/// 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<String> {
let lines: Vec<&str> = body.lines().collect();
let mut media = 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);
} 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));
}
}
}
}
let best = variants.iter().max_by_key(|(h, _, _)| *h)?;
let mut out = String::from("#EXTM3U
#EXT-X-INDEPENDENT-SEGMENTS
");
for m in media {
out.push_str(m);
out.push('\n');
}
out.push_str(best.1);
out.push('\n');
out.push_str(best.2);
out.push('\n');
Some(out)
}
/// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute.
fn resolution_height(stream_inf: &str) -> Option<u32> {
let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len();
let rest = &stream_inf[at..];
let value = rest.split(&[',', ' '][..]).next()?;
value.split(&['x', 'X'][..]).nth(1)?.parse().ok()
}
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
@@ -642,5 +726,59 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
app_data,
children: Arc::new(Mutex::new(HashMap::new())),
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
playlists: PlaylistServer::start()?,
})
}
#[cfg(test)]
mod tests {
use super::filter_master_playlist;
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",
"https://a/360.m3u8\n",
"#EXT-X-STREAM-INF:BANDWIDTH=3878958,CODECS=\"avc1,mp4a\",RESOLUTION=1280x720,AUDIO=\"234\"\n",
"https://a/720.m3u8\n",
"#EXT-X-STREAM-INF:BANDWIDTH=6039686,CODECS=\"avc1,mp4a\",RESOLUTION=1920x1080,AUDIO=\"234\"\n",
"https://a/1080.m3u8\n",
);
#[test]
fn keeps_the_best_variant_at_or_below_the_cap() {
let out = filter_master_playlist(MASTER, 720).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"));
}
#[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());
}
#[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());
}
}