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:
@@ -220,6 +220,17 @@ async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
||||
.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<Vec<ChannelWithCount>, 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 `[<id>]` tag in its filename.
|
||||
async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option<String> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-1
@@ -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<Box<dyn rusqlite::ToSql>> = 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();
|
||||
|
||||
+50
-12
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,6 +82,9 @@ pub struct FeedItem {
|
||||
pub path: Option<String>,
|
||||
pub pct: Option<f64>,
|
||||
pub error: Option<String>,
|
||||
/// Seconds watched, and the video's length — drives the feed progress bar.
|
||||
pub position: Option<f64>,
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user