diff --git a/app-icon.svg b/app-icon.svg new file mode 100644 index 0000000..0c886d3 --- /dev/null +++ b/app-icon.svg @@ -0,0 +1,52 @@ + + FlightTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..83b0e80 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..a009361 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..e75ce92 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..3a0d3f5 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..c97d182 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..d3895b3 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..3a655b6 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..0cb8e03 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..672f89a 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..085563f 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..c7e676d 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..f2174b7 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..604d2d2 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..c26a2e8 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..aaa58a8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a7770f6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9a3c6f6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..8009c31 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..325f009 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..78b601a Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..be63140 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..608e59a Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4f4e9e2 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e300f30 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c43c976 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b896391 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..670339f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f676456 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2b5edd0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..3b53587 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..b3ef4ab 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..7de079d 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..37019ce Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..3bfc6d9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..3bfc6d9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..4a226f5 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..eb5952f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..c4487dd Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..c4487dd Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..f87063b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..3bfc6d9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..ed0c597 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..ed0c597 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..1abfa28 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..bbf5b89 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..1abfa28 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..f247219 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..f7bdfcb Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4274838 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..2c1b0c0 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6ef4017..b289933 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -220,6 +220,17 @@ async fn yt_dlp_print(args: &[&str]) -> Option { .map(str::to_string) } +/// Called periodically while a video plays, and once when the player closes. +#[tauri::command] +pub async fn save_playback( + video_id: String, + position: f64, + duration: f64, + state: State<'_, AppState>, +) -> Result<(), String> { + state.db.lock().await.save_playback(&video_id, position, duration) +} + #[tauri::command] pub async fn list_channels(state: State<'_, AppState>) -> Result, String> { state.db.lock().await.list_channels() @@ -333,6 +344,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) { #[tauri::command] pub async fn download_video( video_id: String, + quality: String, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -362,8 +374,11 @@ pub async fn download_video( .await .map_err(|e| format!("Download queue closed: {e}"))?; - let out_template = library.join("%(id)s.%(ext)s").to_string_lossy().to_string(); - let args = downloader::build_args(&video_id, &out_template); + let out_template = library + .join(downloader::OUTPUT_TEMPLATE) + .to_string_lossy() + .to_string(); + let args = downloader::build_args(&video_id, &out_template, &quality); let mut child = tokio::process::Command::new(bin("yt-dlp")) .args(&args) @@ -462,12 +477,14 @@ pub async fn download_video( drop(permit); if status.success() { - let path = final_path.unwrap_or_else(|| { - library - .join(format!("{video_id}.mp4")) - .to_string_lossy() - .to_string() - }); + // yt-dlp normally reports the path via `--print after_move:`; if that + // line went missing, find the file it wrote by its embedded video id. + let path = match final_path { + Some(p) => p, + None => find_by_video_id(&library, &video_id) + .await + .ok_or("Download finished but the file could not be located.")?, + }; let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Done, None)?; db.set_download_path(&video_id, &path)?; @@ -531,6 +548,18 @@ pub async fn cancel_download( Ok(()) } +/// Locates a finished download by the `[]` tag in its filename. +async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option { + let mut entries = tokio::fs::read_dir(library).await.ok()?; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if name.contains(video_id) && !name.contains(".part") && !name.ends_with(".ytdl") { + return Some(entry.path().to_string_lossy().to_string()); + } + } + None +} + /// yt-dlp leaves `.part`, `.ytdl` and format-specific fragments behind when /// killed; without this the library slowly fills with dead bytes. async fn cleanup_partials(library: PathBuf, video_id: &str) { @@ -539,7 +568,7 @@ async fn cleanup_partials(library: PathBuf, video_id: &str) { }; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) { + if name.contains(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) { let _ = tokio::fs::remove_file(entry.path()).await; } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ee404cb..4e6cff6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -34,6 +34,13 @@ CREATE TABLE IF NOT EXISTS videos ( CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published DESC); CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(channel_id); +CREATE TABLE IF NOT EXISTS playback ( + video_id TEXT PRIMARY KEY, + position REAL NOT NULL, + duration REAL NOT NULL, + updated_at INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS downloads ( video_id TEXT PRIMARY KEY, state TEXT NOT NULL, @@ -185,6 +192,8 @@ impl Db { tx.execute_batch( "DELETE FROM downloads WHERE video_id IN ( SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); + DELETE FROM playback WHERE video_id IN ( + SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); DELETE FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids); DELETE FROM channels WHERE id NOT IN (SELECT id FROM keep_ids);", ) @@ -340,10 +349,11 @@ impl Db { let mut sql = String::from( "SELECT v.id, v.channel_id, COALESCE(c.title, ''), v.title, v.description, v.published, v.thumb_url, v.thumb_path, v.views, v.is_short, - d.state, d.path, d.pct, d.error + d.state, d.path, d.pct, d.error, p.position, p.duration FROM videos v LEFT JOIN channels c ON c.id = v.channel_id LEFT JOIN downloads d ON d.video_id = v.id + LEFT JOIN playback p ON p.video_id = v.id WHERE 1=1", ); let mut args: Vec> = Vec::new(); @@ -391,6 +401,8 @@ impl Db { path: r.get(11)?, pct: r.get(12)?, error: r.get(13)?, + position: r.get(14)?, + duration: r.get(15)?, }) }) .map_err(|e| e.to_string())?; @@ -399,6 +411,26 @@ impl Db { .map_err(|e| e.to_string()) } + /// Remembers where playback got to, so the feed can show a progress bar and + /// reopening a video can resume it. + pub fn save_playback(&self, video_id: &str, position: f64, duration: f64) -> Result<(), String> { + if !(position.is_finite() && duration.is_finite()) || duration <= 0.0 { + return Ok(()); + } + self.conn + .execute( + "INSERT INTO playback (video_id, position, duration, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(video_id) DO UPDATE SET + position = excluded.position, + duration = excluded.duration, + updated_at = excluded.updated_at", + params![video_id, position.max(0.0), duration, now()], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + pub fn set_download_state( &self, video_id: &str, @@ -517,6 +549,50 @@ mod tests { db } + #[test] + fn playback_position_surfaces_on_the_feed() { + let db = seeded(); + db.save_playback("a", 30.0, 120.0).unwrap(); + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + let a = feed.iter().find(|f| f.id == "a").unwrap(); + assert_eq!(a.position, Some(30.0)); + assert_eq!(a.duration, Some(120.0)); + // Videos never opened have no bar to draw. + let b = feed.iter().find(|f| f.id == "b").unwrap(); + assert_eq!(b.position, None); + } + + #[test] + fn saving_playback_twice_updates_in_place() { + let db = seeded(); + db.save_playback("a", 10.0, 120.0).unwrap(); + db.save_playback("a", 90.0, 120.0).unwrap(); + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(90.0)); + } + + #[test] + fn nonsense_playback_values_are_ignored() { + let db = seeded(); + db.save_playback("a", f64::NAN, 120.0).unwrap(); + db.save_playback("a", 5.0, 0.0).unwrap(); + db.save_playback("a", 5.0, f64::INFINITY).unwrap(); + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, None); + } + + #[test] + fn replacing_drops_playback_of_removed_channels() { + let mut db = seeded(); + db.save_playback("a", 10.0, 120.0).unwrap(); + db.save_playback("b", 10.0, 120.0).unwrap(); + db.replace_channels(&[Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into() }]) + .unwrap(); + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + assert!(feed.iter().all(|f| f.id != "b")); + assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(10.0)); + } + #[test] fn replacing_drops_channels_absent_from_the_new_csv() { let mut db = seeded(); diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index dddc22a..c21125b 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -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 { /// 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 { +pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec { 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!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e6e73f..675e6ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ pub fn run() { commands::list_channels, commands::list_feed, commands::resolve_stream, + commands::save_playback, commands::refresh_feeds, commands::download_video, commands::cancel_download, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 1554fca..a924c37 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -82,6 +82,9 @@ pub struct FeedItem { pub path: Option, pub pct: Option, pub error: Option, + /// Seconds watched, and the video's length — drives the feed progress bar. + pub position: Option, + pub duration: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e744069..72ed221 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ "minWidth": 900, "minHeight": 560, "center": true, - "titleBarStyle": "Transparent", + "titleBarStyle": "Overlay", "hiddenTitle": true } ], diff --git a/src/App.tsx b/src/App.tsx index 4bef4e1..bf78c28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { useAppearance } from "./hooks/useAppearance"; import { useConnectivity } from "./hooks/useConnectivity"; import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; -import type { FeedFilter, FeedItem, RefreshProgress } from "./types"; +import type { FeedFilter, FeedItem, Quality, RefreshProgress } from "./types"; const TOAST_MS = 2400; @@ -30,7 +30,17 @@ export default function App() { } }); const [showSettings, setShowSettings] = useState(false); - const [playing, setPlaying] = useState<{ item: FeedItem; path: string | null } | null>(null); + // Index into the current feed, so the player can step through it. + const [playingIndex, setPlayingIndex] = useState(null); + const [quality, setQuality] = useState(() => { + try { + return localStorage.getItem("flighttube.quality") === "compatible" + ? "compatible" + : "best"; + } catch { + return "best"; + } + }); const [refreshing, setRefreshing] = useState(false); const [refreshProgress, setRefreshProgress] = useState(null); const [toast, setToast] = useState(null); @@ -52,10 +62,11 @@ export default function App() { useEffect(() => { try { localStorage.setItem("flighttube.view", view); + localStorage.setItem("flighttube.quality", quality); } catch { /* storage blocked */ } - }, [view]); + }, [view, quality]); // Offline, the only videos that can be played are the ones already on disk, // so the feed collapses to those regardless of the toggle. @@ -116,23 +127,45 @@ export default function App() { } }, [reload, say]); - const openItem = useCallback( - (item: FeedItem) => { + // Downloaded plays from disk; anything else streams. Only being offline with + // no local copy leaves nothing to play. + const playableAt = useCallback( + (i: number): { item: FeedItem; path: string | null } | null => { + const item = items[i]; + if (!item) return null; const path = live[item.id]?.path ?? item.path; const done = (live[item.id]?.state ?? item.state) === "done"; - // Downloaded plays from disk; anything else streams YouTube's embed in - // the app. Only being offline with no local copy leaves nothing to play. - if (done && path) { - setPlaying({ item, path }); - } else if (online) { - setPlaying({ item, path: null }); - } else { - setFailure("That video isn't downloaded, and you're offline."); - } + if (done && path) return { item, path }; + return online ? { item, path: null } : null; }, - [live, online], + [items, live, online], ); + const openIndex = useCallback( + (i: number) => { + if (playableAt(i)) setPlayingIndex(i); + else setFailure("That video isn't downloaded, and you're offline."); + }, + [playableAt], + ); + + /** Next/previous item that can actually be played right now. */ + const stepFrom = useCallback( + (from: number, dir: 1 | -1): number | null => { + for (let i = from + dir; i >= 0 && i < items.length; i += dir) { + if (playableAt(i)) return i; + } + return null; + }, + [items.length, playableAt], + ); + + const playing = playingIndex == null ? null : playableAt(playingIndex); + // The feed can change under an open player (a refresh, a filter change). + useEffect(() => { + if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null); + }, [items, playingIndex]); + const emptyMessage = () => { if (loading) return "Loading…"; if (channels.length === 0) @@ -200,14 +233,14 @@ export default function App() { : "mx-auto max-w-4xl space-y-1.5" } > - {items.map((item) => { + {items.map((item, idx) => { const shared = { item, live: live[item.id], online, - onOpen: () => openItem(item), + onOpen: () => openIndex(idx), onDownload: () => - downloadVideo(item.id).catch((e) => setFailure(String(e))), + downloadVideo(item.id, quality).catch((e) => setFailure(String(e))), onCancel: () => cancelDownload(item.id).catch((e) => setFailure(String(e))), onDelete: () => @@ -227,16 +260,32 @@ export default function App() { - {playing && ( - setPlaying(null)} + {playing && playingIndex != null && ( + setPlayingIndex(stepFrom(playingIndex, -1)) + : undefined + } + onNext={ + stepFrom(playingIndex, 1) != null + ? () => setPlayingIndex(stepFrom(playingIndex, 1)) + : undefined + } + onClose={() => { setPlayingIndex(null); reload(); }} onDelete={async () => { await deleteDownload(playing.item.id); clearLive(playing.item.id); - setPlaying(null); + setPlayingIndex(null); reload(); say("Download deleted"); - }} /> + }} + /> )} {showSettings && ( @@ -244,6 +293,8 @@ export default function App() { onClose={() => setShowSettings(false)} appearance={mode} onAppearance={setMode} + quality={quality} + onQuality={setQuality} onError={setFailure} onImported={(n) => { reload(); diff --git a/src/api.ts b/src/api.ts index 2f24bc2..c714ccc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,6 +9,7 @@ import type { FeedItem, ImportPreview, Prereqs, + Quality, RefreshProgress, RefreshSummary, } from "./types"; @@ -22,8 +23,11 @@ export const listFeed = (filter: FeedFilter) => export const refreshFeeds = () => invoke("refresh_feeds"); -export const downloadVideo = (videoId: string) => - invoke("download_video", { videoId }); +export const downloadVideo = (videoId: string, quality: Quality) => + invoke("download_video", { videoId, quality }); + +export const savePlayback = (videoId: string, position: number, duration: number) => + invoke("save_playback", { videoId, position, duration }); export const cancelDownload = (videoId: string) => invoke("cancel_download", { videoId }); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index bc9caf9..faaa27b 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; -import { fileUrl, openExternal, resolveStream } from "../api"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { fileUrl, openExternal, resolveStream, savePlayback } from "../api"; import type { FeedItem } from "../types"; import { compactViews, relativeTime } from "./format"; -import { BTN, BTN_CHROME, BTN_QUIET } from "./ui"; +import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui"; interface Props { item: FeedItem; @@ -10,8 +10,61 @@ interface Props { path: string | null; onClose: () => void; onDelete: () => void; + onPrev?: () => void; + onNext?: () => void; + /** Position in the current feed, for the "3 of 180" readout. */ + index: number; + total: number; } +/** + * Releases a