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.
126 lines
4.1 KiB
Rust
126 lines
4.1 KiB
Rust
//! 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
|
|
}
|