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
+77 -1
View File
@@ -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();