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:
@@ -9,6 +9,7 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-is-fullscreen"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen"]}}
|
||||
+142
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod downloader;
|
||||
pub mod feed;
|
||||
pub mod models;
|
||||
pub mod net;
|
||||
pub mod playlist_server;
|
||||
pub mod takeout;
|
||||
pub mod thumbs;
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! A minimal loopback HTTP server for rewritten HLS playlists.
|
||||
//!
|
||||
//! When a streaming quality cap is set we hand the player a filtered master
|
||||
//! playlist rather than YouTube's. That playlist has to be reachable over
|
||||
//! `http://`: Safari's native HLS is backed by AVFoundation, which cannot read
|
||||
//! `blob:` or custom-scheme URLs — only a real HTTP one. Hence ~80 lines of
|
||||
//! server rather than a one-line Blob URL.
|
||||
//!
|
||||
//! It binds to 127.0.0.1 on an ephemeral port and only ever serves playlists it
|
||||
//! was explicitly handed, addressed by an unguessable token.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Playlists are small; keeping the last few is plenty, and it stops a long
|
||||
/// session from growing the map without bound.
|
||||
const MAX_ENTRIES: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaylistServer {
|
||||
port: u16,
|
||||
entries: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
impl PlaylistServer {
|
||||
pub fn start() -> Result<Self, String> {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("Cannot bind playlist server: {e}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| format!("Cannot configure playlist server: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Cannot read playlist server port: {e}"))?
|
||||
.port();
|
||||
|
||||
let server = PlaylistServer {
|
||||
port,
|
||||
entries: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
|
||||
let entries = server.entries.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let Ok(listener) = TcpListener::from_std(listener) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
let entries = entries.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = serve(stream, entries).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Publishes a playlist and returns the URL to hand the player.
|
||||
pub async fn publish(&self, body: String) -> String {
|
||||
let token = token();
|
||||
let mut entries = self.entries.lock().await;
|
||||
entries.push((token.clone(), body));
|
||||
if entries.len() > MAX_ENTRIES {
|
||||
entries.remove(0);
|
||||
}
|
||||
format!("http://127.0.0.1:{}/{token}.m3u8", self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Not cryptographic, just unguessable enough that another local process
|
||||
/// cannot stumble onto a playlist by iterating short paths.
|
||||
fn token() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let addr = Box::into_raw(Box::new(0u8)) as usize;
|
||||
// Reclaim the allocation used purely as an address source.
|
||||
unsafe { drop(Box::from_raw(addr as *mut u8)) };
|
||||
format!("{nanos:x}{addr:x}")
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
entries: Arc<Mutex<Vec<(String, String)>>>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let n = stream.read(&mut buf).await?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
// "GET /<token>.m3u8 HTTP/1.1"
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.unwrap_or("");
|
||||
let wanted = path.trim_start_matches('/').trim_end_matches(".m3u8");
|
||||
|
||||
let body = {
|
||||
let entries = entries.lock().await;
|
||||
entries
|
||||
.iter()
|
||||
.find(|(t, _)| t == wanted)
|
||||
.map(|(_, b)| b.clone())
|
||||
};
|
||||
|
||||
let response = match body {
|
||||
Some(body) => format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\n\
|
||||
Content-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
),
|
||||
None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(),
|
||||
};
|
||||
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.flush().await
|
||||
}
|
||||
Reference in New Issue
Block a user