feat: 4K downloads, watch progress, player controls, new icon

Window chrome: titleBarStyle Overlay (Transparent left the native bar in
place, white in dark mode, with a dead strip beneath it). The app now
paints that strip itself, so it matches the page background.

Player: prev/next through the feed, a full-screen button, a spinner
while a stream resolves, Open on YouTube on downloaded videos too, and
the description collapsed behind a disclosure.

Downloads default to Best, which reaches real 4K — above 1080p YouTube
serves VP9/AV1, verified to play natively in WKWebView here. Audio stays
pinned to AAC because Opus in MP4 would be silent. A Compatible setting
keeps the old 1080p H.264 behaviour. Files are now named
'<ISO date> - <title> [<id>].mp4'.

Watch progress is recorded and drawn under thumbnails like YouTube's,
and reopening a video resumes where it left off.

Tiles clamp every text line to a fixed height so they share a baseline,
and the search field no longer clips its placeholder.

Fixes a Picture-in-Picture leak: WebKit kept a detached video playing
after the player closed, so a second video could play over the first
with no way to stop it. Every exit path now tears the element down.
This commit is contained in:
vincent
2026-08-29 04:03:45 +02:00
parent d0bad64d7c
commit 5b09acd28d
69 changed files with 582 additions and 89 deletions
+50 -12
View File
@@ -1,16 +1,31 @@
//! Driving `yt-dlp` and interpreting its progress output.
//!
//! The format selector is deliberately narrow: H.264 video plus AAC audio in an
//! MP4 container. YouTube only serves H.264 up to 1080p — everything above that
//! is VP9 or AV1, which WKWebView cannot reliably play. Since FlightTube plays
//! downloads in its own window, a 4K file we cannot decode is worthless. Do not
//! widen this selector without also solving playback.
pub const FORMAT_SELECTOR: &str = "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*+ba/b";
/// Highest resolution available, which on YouTube means VP9 or AV1 above 1080p.
/// Audio is still pinned to AAC (`m4a`): YouTube pairs those codecs with Opus,
/// which WebKit will not decode inside an MP4 container, so taking Opus would
/// yield a silent file.
pub const FORMAT_BEST: &str = "bv*+ba[ext=m4a]/bv*+ba/b";
/// H.264 video plus AAC audio. Caps at 1080p — YouTube serves H.264 no higher —
/// but is guaranteed to decode in WKWebView on any Mac.
pub const FORMAT_COMPATIBLE: &str =
"bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/bv*+ba/b";
pub fn format_selector(quality: &str) -> &'static str {
match quality {
"compatible" => FORMAT_COMPATIBLE,
_ => FORMAT_BEST,
}
}
/// Sentinel prefix so progress lines are distinguishable from yt-dlp's ordinary
/// chatter on the same stream.
pub const PROGRESS_TEMPLATE: &str = "FTPROG %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s";
/// Readable, sortable filenames: upload date, then title, then the video id so
/// two videos sharing a title cannot collide.
pub const OUTPUT_TEMPLATE: &str = "%(upload_date>%Y-%m-%d)s - %(title)s [%(id)s].%(ext)s";
#[derive(Debug, Clone, PartialEq)]
pub struct Progress {
pub downloaded: u64,
@@ -61,16 +76,19 @@ pub fn parse_progress_line(line: &str) -> Option<Progress> {
/// Arguments for downloading one video. Kept separate from process spawning so
/// the argument construction is assertable in tests.
pub fn build_args(video_id: &str, out_template: &str) -> Vec<String> {
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
vec![
"-f".into(),
FORMAT_SELECTOR.into(),
format_selector(quality).into(),
"--merge-output-format".into(),
"mp4".into(),
"--no-playlist".into(),
"--newline".into(),
"--no-colors".into(),
"--progress".into(),
// Long video titles make long filenames; keep them within sane limits.
"--trim-filenames".into(),
"180".into(),
"--progress-template".into(),
PROGRESS_TEMPLATE.into(),
"--print".into(),
@@ -155,14 +173,34 @@ mod tests {
}
#[test]
fn args_pin_h264_and_mp4() {
let args = build_args("abc123", "/tmp/%(id)s.%(ext)s");
assert!(args.contains(&FORMAT_SELECTOR.to_string()));
fn compatible_quality_pins_h264_and_aac() {
let args = build_args("abc123", "/tmp/out.%(ext)s", "compatible");
assert!(args.contains(&FORMAT_COMPATIBLE.to_string()));
assert!(args.contains(&"mp4".to_string()));
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
assert!(args.contains(&"--no-playlist".to_string()));
}
#[test]
fn best_quality_still_pins_aac_audio() {
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
assert!(args.contains(&FORMAT_BEST.to_string()));
// Opus in MP4 would be silent in WebKit, so the audio half stays m4a.
assert!(FORMAT_BEST.contains("ba[ext=m4a]"));
}
#[test]
fn unknown_quality_falls_back_to_best() {
assert_eq!(format_selector("nonsense"), FORMAT_BEST);
assert_eq!(format_selector("compatible"), FORMAT_COMPATIBLE);
}
#[test]
fn output_template_is_date_then_title_then_id() {
assert!(OUTPUT_TEMPLATE.starts_with("%(upload_date>%Y-%m-%d)s - %(title)s"));
assert!(OUTPUT_TEMPLATE.contains("[%(id)s]"));
}
#[test]
fn extracts_final_path() {
assert_eq!(