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
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<title>FlightTube</title>
<defs>
<!-- Deep slate, matching the app's dark page background. -->
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#1e293b"/>
<stop offset="0.55" stop-color="#0f172b"/>
<stop offset="1" stop-color="#020618"/>
</linearGradient>
<!-- A single soft light source, upper left, so the mark has somewhere to sit. -->
<radialGradient id="glow" cx="0.26" cy="0.2" r="0.85">
<stop offset="0" stop-color="#38bdf8" stop-opacity="0.30"/>
<stop offset="0.55" stop-color="#38bdf8" stop-opacity="0.06"/>
<stop offset="1" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- The lit face: white falling to the accent, following the same light. -->
<linearGradient id="face" x1="0.15" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#e0f2fe"/>
</linearGradient>
<!-- The folded-under face, in the accent so the plane reads as one object. -->
<linearGradient id="fold" x1="0.2" y1="0" x2="0.85" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#0284c7"/>
</linearGradient>
<filter id="lift" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#020618" flood-opacity="0.45"/>
</filter>
</defs>
<!-- macOS-style rounded square; the platform does not mask for us. -->
<rect width="1024" height="1024" rx="228" ry="228" fill="url(#bg)"/>
<rect width="1024" height="1024" rx="228" ry="228" fill="url(#glow)"/>
<!--
One paper plane, two faces. The upper face is a clean right-pointing
triangle, so the same silhouette reads as a play button — the app is
"watch your subscriptions on a flight" in a single shape.
-->
<g filter="url(#lift)">
<path d="M 832 206 L 192 486 L 446 566 Z" fill="url(#face)"/>
<path d="M 832 206 L 446 566 L 556 828 Z" fill="url(#fold)"/>
</g>
<!-- Hairline inner edge, the same trick the UI uses: a border, not a shadow. -->
<rect x="6" y="6" width="1012" height="1012" rx="222" ry="222"
fill="none" stroke="#ffffff" stroke-opacity="0.07" stroke-width="12"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+38 -9
View File
@@ -220,6 +220,17 @@ async fn yt_dlp_print(args: &[&str]) -> Option<String> {
.map(str::to_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] #[tauri::command]
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> { pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
state.db.lock().await.list_channels() state.db.lock().await.list_channels()
@@ -333,6 +344,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) {
#[tauri::command] #[tauri::command]
pub async fn download_video( pub async fn download_video(
video_id: String, video_id: String,
quality: String,
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -362,8 +374,11 @@ pub async fn download_video(
.await .await
.map_err(|e| format!("Download queue closed: {e}"))?; .map_err(|e| format!("Download queue closed: {e}"))?;
let out_template = library.join("%(id)s.%(ext)s").to_string_lossy().to_string(); let out_template = library
let args = downloader::build_args(&video_id, &out_template); .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")) let mut child = tokio::process::Command::new(bin("yt-dlp"))
.args(&args) .args(&args)
@@ -462,12 +477,14 @@ pub async fn download_video(
drop(permit); drop(permit);
if status.success() { if status.success() {
let path = final_path.unwrap_or_else(|| { // yt-dlp normally reports the path via `--print after_move:`; if that
library // line went missing, find the file it wrote by its embedded video id.
.join(format!("{video_id}.mp4")) let path = match final_path {
.to_string_lossy() Some(p) => p,
.to_string() 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; let db = state.db.lock().await;
db.set_download_state(&video_id, DownloadState::Done, None)?; db.set_download_state(&video_id, DownloadState::Done, None)?;
db.set_download_path(&video_id, &path)?; db.set_download_path(&video_id, &path)?;
@@ -531,6 +548,18 @@ pub async fn cancel_download(
Ok(()) 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 /// yt-dlp leaves `.part`, `.ytdl` and format-specific fragments behind when
/// killed; without this the library slowly fills with dead bytes. /// killed; without this the library slowly fills with dead bytes.
async fn cleanup_partials(library: PathBuf, video_id: &str) { 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 { while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string(); 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; let _ = tokio::fs::remove_file(entry.path()).await;
} }
} }
+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_published ON videos(published DESC);
CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(channel_id); 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 ( CREATE TABLE IF NOT EXISTS downloads (
video_id TEXT PRIMARY KEY, video_id TEXT PRIMARY KEY,
state TEXT NOT NULL, state TEXT NOT NULL,
@@ -185,6 +192,8 @@ impl Db {
tx.execute_batch( tx.execute_batch(
"DELETE FROM downloads WHERE video_id IN ( "DELETE FROM downloads WHERE video_id IN (
SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); 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 videos WHERE channel_id NOT IN (SELECT id FROM keep_ids);
DELETE FROM channels WHERE 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( let mut sql = String::from(
"SELECT v.id, v.channel_id, COALESCE(c.title, ''), v.title, v.description, "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, 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 FROM videos v
LEFT JOIN channels c ON c.id = v.channel_id LEFT JOIN channels c ON c.id = v.channel_id
LEFT JOIN downloads d ON d.video_id = v.id LEFT JOIN downloads d ON d.video_id = v.id
LEFT JOIN playback p ON p.video_id = v.id
WHERE 1=1", WHERE 1=1",
); );
let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
@@ -391,6 +401,8 @@ impl Db {
path: r.get(11)?, path: r.get(11)?,
pct: r.get(12)?, pct: r.get(12)?,
error: r.get(13)?, error: r.get(13)?,
position: r.get(14)?,
duration: r.get(15)?,
}) })
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -399,6 +411,26 @@ impl Db {
.map_err(|e| e.to_string()) .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( pub fn set_download_state(
&self, &self,
video_id: &str, video_id: &str,
@@ -517,6 +549,50 @@ mod tests {
db 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] #[test]
fn replacing_drops_channels_absent_from_the_new_csv() { fn replacing_drops_channels_absent_from_the_new_csv() {
let mut db = seeded(); let mut db = seeded();
+50 -12
View File
@@ -1,16 +1,31 @@
//! Driving `yt-dlp` and interpreting its progress output. //! Driving `yt-dlp` and interpreting its progress output.
//!
//! The format selector is deliberately narrow: H.264 video plus AAC audio in an /// Highest resolution available, which on YouTube means VP9 or AV1 above 1080p.
//! MP4 container. YouTube only serves H.264 up to 1080p — everything above that /// Audio is still pinned to AAC (`m4a`): YouTube pairs those codecs with Opus,
//! is VP9 or AV1, which WKWebView cannot reliably play. Since FlightTube plays /// which WebKit will not decode inside an MP4 container, so taking Opus would
//! downloads in its own window, a 4K file we cannot decode is worthless. Do not /// yield a silent file.
//! widen this selector without also solving playback. pub const FORMAT_BEST: &str = "bv*+ba[ext=m4a]/bv*+ba/b";
pub const FORMAT_SELECTOR: &str = "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/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 /// Sentinel prefix so progress lines are distinguishable from yt-dlp's ordinary
/// chatter on the same stream. /// 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"; 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)] #[derive(Debug, Clone, PartialEq)]
pub struct Progress { pub struct Progress {
pub downloaded: u64, 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 /// Arguments for downloading one video. Kept separate from process spawning so
/// the argument construction is assertable in tests. /// 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![ vec![
"-f".into(), "-f".into(),
FORMAT_SELECTOR.into(), format_selector(quality).into(),
"--merge-output-format".into(), "--merge-output-format".into(),
"mp4".into(), "mp4".into(),
"--no-playlist".into(), "--no-playlist".into(),
"--newline".into(), "--newline".into(),
"--no-colors".into(), "--no-colors".into(),
"--progress".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(),
PROGRESS_TEMPLATE.into(), PROGRESS_TEMPLATE.into(),
"--print".into(), "--print".into(),
@@ -155,14 +173,34 @@ mod tests {
} }
#[test] #[test]
fn args_pin_h264_and_mp4() { fn compatible_quality_pins_h264_and_aac() {
let args = build_args("abc123", "/tmp/%(id)s.%(ext)s"); let args = build_args("abc123", "/tmp/out.%(ext)s", "compatible");
assert!(args.contains(&FORMAT_SELECTOR.to_string())); assert!(args.contains(&FORMAT_COMPATIBLE.to_string()));
assert!(args.contains(&"mp4".to_string())); assert!(args.contains(&"mp4".to_string()));
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string())); assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
assert!(args.contains(&"--no-playlist".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] #[test]
fn extracts_final_path() { fn extracts_final_path() {
assert_eq!( assert_eq!(
+1
View File
@@ -26,6 +26,7 @@ pub fn run() {
commands::list_channels, commands::list_channels,
commands::list_feed, commands::list_feed,
commands::resolve_stream, commands::resolve_stream,
commands::save_playback,
commands::refresh_feeds, commands::refresh_feeds,
commands::download_video, commands::download_video,
commands::cancel_download, commands::cancel_download,
+3
View File
@@ -82,6 +82,9 @@ pub struct FeedItem {
pub path: Option<String>, pub path: Option<String>,
pub pct: Option<f64>, pub pct: Option<f64>,
pub error: Option<String>, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
+1 -1
View File
@@ -18,7 +18,7 @@
"minWidth": 900, "minWidth": 900,
"minHeight": 560, "minHeight": 560,
"center": true, "center": true,
"titleBarStyle": "Transparent", "titleBarStyle": "Overlay",
"hiddenTitle": true "hiddenTitle": true
} }
], ],
+74 -23
View File
@@ -13,7 +13,7 @@ import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity"; import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads"; import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed"; import { useFeed } from "./hooks/useFeed";
import type { FeedFilter, FeedItem, RefreshProgress } from "./types"; import type { FeedFilter, FeedItem, Quality, RefreshProgress } from "./types";
const TOAST_MS = 2400; const TOAST_MS = 2400;
@@ -30,7 +30,17 @@ export default function App() {
} }
}); });
const [showSettings, setShowSettings] = useState(false); 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<number | null>(null);
const [quality, setQuality] = useState<Quality>(() => {
try {
return localStorage.getItem("flighttube.quality") === "compatible"
? "compatible"
: "best";
} catch {
return "best";
}
});
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null); const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
@@ -52,10 +62,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem("flighttube.view", view); localStorage.setItem("flighttube.view", view);
localStorage.setItem("flighttube.quality", quality);
} catch { } catch {
/* storage blocked */ /* storage blocked */
} }
}, [view]); }, [view, quality]);
// Offline, the only videos that can be played are the ones already on disk, // Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle. // so the feed collapses to those regardless of the toggle.
@@ -116,23 +127,45 @@ export default function App() {
} }
}, [reload, say]); }, [reload, say]);
const openItem = useCallback( // Downloaded plays from disk; anything else streams. Only being offline with
(item: FeedItem) => { // 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 path = live[item.id]?.path ?? item.path;
const done = (live[item.id]?.state ?? item.state) === "done"; const done = (live[item.id]?.state ?? item.state) === "done";
// Downloaded plays from disk; anything else streams YouTube's embed in if (done && path) return { item, path };
// the app. Only being offline with no local copy leaves nothing to play. return online ? { item, path: null } : null;
if (done && path) {
setPlaying({ item, path });
} else if (online) {
setPlaying({ item, path: null });
} else {
setFailure("That video isn't downloaded, and you're offline.");
}
}, },
[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 = () => { const emptyMessage = () => {
if (loading) return "Loading…"; if (loading) return "Loading…";
if (channels.length === 0) if (channels.length === 0)
@@ -200,14 +233,14 @@ export default function App() {
: "mx-auto max-w-4xl space-y-1.5" : "mx-auto max-w-4xl space-y-1.5"
} }
> >
{items.map((item) => { {items.map((item, idx) => {
const shared = { const shared = {
item, item,
live: live[item.id], live: live[item.id],
online, online,
onOpen: () => openItem(item), onOpen: () => openIndex(idx),
onDownload: () => onDownload: () =>
downloadVideo(item.id).catch((e) => setFailure(String(e))), downloadVideo(item.id, quality).catch((e) => setFailure(String(e))),
onCancel: () => onCancel: () =>
cancelDownload(item.id).catch((e) => setFailure(String(e))), cancelDownload(item.id).catch((e) => setFailure(String(e))),
onDelete: () => onDelete: () =>
@@ -227,16 +260,32 @@ export default function App() {
</main> </main>
</div> </div>
{playing && ( {playing && playingIndex != null && (
<Player item={playing.item} path={playing.path} <Player
onClose={() => setPlaying(null)} key={playing.item.id}
item={playing.item}
path={playing.path}
index={playingIndex}
total={items.length}
onPrev={
stepFrom(playingIndex, -1) != null
? () => setPlayingIndex(stepFrom(playingIndex, -1))
: undefined
}
onNext={
stepFrom(playingIndex, 1) != null
? () => setPlayingIndex(stepFrom(playingIndex, 1))
: undefined
}
onClose={() => { setPlayingIndex(null); reload(); }}
onDelete={async () => { onDelete={async () => {
await deleteDownload(playing.item.id); await deleteDownload(playing.item.id);
clearLive(playing.item.id); clearLive(playing.item.id);
setPlaying(null); setPlayingIndex(null);
reload(); reload();
say("Download deleted"); say("Download deleted");
}} /> }}
/>
)} )}
{showSettings && ( {showSettings && (
@@ -244,6 +293,8 @@ export default function App() {
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
appearance={mode} appearance={mode}
onAppearance={setMode} onAppearance={setMode}
quality={quality}
onQuality={setQuality}
onError={setFailure} onError={setFailure}
onImported={(n) => { onImported={(n) => {
reload(); reload();
+6 -2
View File
@@ -9,6 +9,7 @@ import type {
FeedItem, FeedItem,
ImportPreview, ImportPreview,
Prereqs, Prereqs,
Quality,
RefreshProgress, RefreshProgress,
RefreshSummary, RefreshSummary,
} from "./types"; } from "./types";
@@ -22,8 +23,11 @@ export const listFeed = (filter: FeedFilter) =>
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds"); export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
export const downloadVideo = (videoId: string) => export const downloadVideo = (videoId: string, quality: Quality) =>
invoke<void>("download_video", { videoId }); invoke<void>("download_video", { videoId, quality });
export const savePlayback = (videoId: string, position: number, duration: number) =>
invoke<void>("save_playback", { videoId, position, duration });
export const cancelDownload = (videoId: string) => export const cancelDownload = (videoId: string) =>
invoke<void>("cancel_download", { videoId }); invoke<void>("cancel_download", { videoId });
+162 -17
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream } from "../api"; import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME, BTN_QUIET } from "./ui"; import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui";
interface Props { interface Props {
item: FeedItem; item: FeedItem;
@@ -10,8 +10,61 @@ interface Props {
path: string | null; path: string | null;
onClose: () => void; onClose: () => void;
onDelete: () => void; onDelete: () => void;
onPrev?: () => void;
onNext?: () => void;
/** Position in the current feed, for the "3 of 180" readout. */
index: number;
total: number;
} }
/**
* Releases a <video> completely.
*
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture
* session (and its audio) running after the element leaves the DOM, so closing
* the player or stepping to the next video would leave the previous one playing
* with no way to stop it. Every exit path goes through here.
*/
function teardown(v: HTMLVideoElement | null) {
if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation.
try {
const webkit = v as HTMLVideoElement & {
webkitPresentationMode?: string;
webkitSetPresentationMode?: (mode: string) => void;
};
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
webkit.webkitSetPresentationMode?.("inline");
}
} catch {
/* not supported here */
}
try {
if (document.pictureInPictureElement) void document.exitPictureInPicture();
} catch {
/* not supported here */
}
try {
if (document.fullscreenElement) void document.exitFullscreen();
} catch {
/* not supported here */
}
try {
v.pause();
// Dropping the source is what actually frees the decoder and the audio.
v.removeAttribute("src");
v.load();
} catch {
/* already gone */
}
}
/** Save at most this often while playing; also saved on close. */
const SAVE_EVERY_MS = 5000;
/** Ignore a saved position this close to either end — nothing useful to resume. */
const RESUME_EDGE_S = 5;
/** /**
* Two sources, one player element. * Two sources, one player element.
* *
@@ -23,10 +76,15 @@ interface Props {
* so watching still happens here rather than in a browser. The iframe embed * so watching still happens here rather than in a browser. The iframe embed
* cannot be used: it rejects a `tauri://` origin with "Error 153". * cannot be used: it rejects a `tauri://` origin with "Error 153".
*/ */
export default function Player({ item, path, onClose, onDelete }: Props) { export default function Player({
item, path, onClose, onDelete, onPrev, onNext, index, total,
}: Props) {
const streaming = path === null; const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null); const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0);
useEffect(() => { useEffect(() => {
if (path) { if (path) {
@@ -44,6 +102,52 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
}; };
}, [item.id, path]); }, [item.id, path]);
const persist = useCallback(() => {
const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
savePlayback(item.id, v.currentTime, v.duration).catch(() => {
/* a lost position is not worth interrupting playback for */
});
}, [item.id]);
// Save on the way out, including when switching to another video. Declared
// before the teardown effect so React runs this cleanup first — the position
// has to be read off the element before its source is dropped.
useEffect(() => () => persist(), [persist]);
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, [src]);
const onTimeUpdate = () => {
const now = Date.now();
if (now - lastSave.current < SAVE_EVERY_MS) return;
lastSave.current = now;
persist();
};
// Pick up where this video was left off, unless that was right at either end.
const onLoadedMetadata = () => {
const v = videoRef.current;
const at = item.position ?? 0;
if (!v || !Number.isFinite(v.duration)) return;
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
};
const goFullscreen = () => {
// The stage rather than the <video>, so our letterboxing travels with it.
stageRef.current?.requestFullscreen?.().catch(() => {
videoRef.current?.requestFullscreen?.().catch(() => {});
});
};
const navBtn =
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 disabled:cursor-not-allowed " +
"disabled:hover:border-slate-300 disabled:hover:text-inherit " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950"> <div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
<div data-tauri-drag-region className="h-9 shrink-0" /> <div data-tauri-drag-region className="h-9 shrink-0" />
@@ -54,12 +158,28 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}> <button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
Back Back
</button> </button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
Prev
</button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
Next
</button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{index + 1}/{total}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400"> <span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title} {item.channel_title}
</span> </span>
<button onClick={goFullscreen} disabled={!src} title="Full screen" className={navBtn}>
Full screen
</button>
{streaming ? ( {streaming ? (
<span className="text-[11px] text-slate-400 dark:text-slate-500"> <span className="text-[11px] text-slate-400 dark:text-slate-500">
{src ? "Streaming" : error ? "Unavailable" : "Resolving"} {src ? "Streaming" : error ? "Unavailable" : "Loading"}
</span> </span>
) : ( ) : (
<button <button
@@ -73,10 +193,19 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
{/* Absolute fill + object-contain, so portrait Shorts and landscape {/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */} videos are both letterboxed to the pane instead of overflowing it. */}
<div className="relative min-h-0 flex-1 bg-slate-950"> <div ref={stageRef} className="relative min-h-0 flex-1 bg-slate-950">
{src ? ( {src ? (
<video key={src} src={src} controls autoPlay <video
className="absolute inset-0 size-full object-contain" /> ref={videoRef}
key={src}
src={src}
controls
autoPlay
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onPause={persist}
className="absolute inset-0 size-full object-contain"
/>
) : ( ) : (
<div className="absolute inset-0 grid place-items-center px-6 text-center"> <div className="absolute inset-0 grid place-items-center px-6 text-center">
{error ? ( {error ? (
@@ -90,34 +219,50 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
</button> </button>
</div> </div>
) : ( ) : (
<p className="text-[12px] text-slate-400">Finding a stream</p> <div className="flex items-center gap-2 text-slate-400">
<Spinner />
<span className="text-[12px]">Finding a stream</span>
</div>
)} )}
</div> </div>
)} )}
</div> </div>
<footer <footer
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4 className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2> <div className="min-w-0">
{streaming && ( <h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`} className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
> >
Open on YouTube Open on YouTube
</button> </button>
)}
</div>
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div> </div>
{/* Collapsed by default — the description is rarely what you came for. */}
{item.description && ( {item.description && (
<p className="mt-3 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300"> <details className="group mt-2">
<summary
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
>
<span className="inline-block transition-transform group-open:rotate-90"></span>{" "}
Description
</summary>
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description} {item.description}
</p> </p>
</details>
)} )}
</footer> </footer>
</div> </div>
+25 -2
View File
@@ -3,7 +3,7 @@ import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport, checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api"; } from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import type { ImportPreview, Prereqs } from "../types"; import type { ImportPreview, Prereqs, Quality } from "../types";
import TakeoutGuide from "./TakeoutGuide"; import TakeoutGuide from "./TakeoutGuide";
import { import {
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL, BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
@@ -14,6 +14,8 @@ interface Props {
onImported: (count: number) => void; onImported: (count: number) => void;
appearance: Appearance; appearance: Appearance;
onAppearance: (a: Appearance) => void; onAppearance: (a: Appearance) => void;
quality: Quality;
onQuality: (q: Quality) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -33,7 +35,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
} }
export default function Settings({ export default function Settings({
onClose, onImported, appearance, onAppearance, onError, onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
}: Props) { }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null); const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
@@ -121,6 +123,27 @@ export default function Settings({
</button> </button>
</section> </section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Download quality</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
<b>Best</b> takes the highest resolution available, up to 4K above 1080p
that means VP9 or AV1, which your Mac decodes but older ones may not, and
the files are several times larger. <b>Compatible</b> caps at 1080p H.264,
which plays anywhere. Audio is AAC either way.
</p>
<div className="mt-2 flex items-center justify-between gap-3">
<span className={LABEL}>Quality</span>
<Segmented
value={quality}
onChange={onQuality}
options={[
{ value: "best", label: "Best (4K)" },
{ value: "compatible", label: "Compatible" },
]}
/>
</div>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800"> <section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading> <SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3"> <div className="mt-2 flex items-center justify-between gap-3">
+1 -1
View File
@@ -69,7 +69,7 @@ export default function TopBar({
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels" placeholder="Search videos and channels"
className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`} className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`}
/> />
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500"> <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
+3 -1
View File
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads"; import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import WatchBar from "./WatchBar";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime } from "./format";
interface Props { interface Props {
@@ -48,12 +49,13 @@ export default function VideoRow({
)} )}
{downloaded && ( {downloaded && (
<span <span
className="absolute bottom-1 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px] className="absolute bottom-1.5 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white" font-bold uppercase tracking-widest leading-none text-white"
> >
Offline Offline
</span> </span>
)} )}
<WatchBar item={item} />
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+27 -12
View File
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads"; import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import WatchBar from "./WatchBar";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime } from "./format";
interface Props { interface Props {
@@ -45,35 +46,49 @@ export default function VideoTile({
)} )}
{downloaded && ( {downloaded && (
<span <span
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px] className="absolute bottom-2 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white" font-bold uppercase tracking-widest leading-none text-white"
> >
Offline Offline
</span> </span>
)} )}
<WatchBar item={item} />
</button> </button>
<div className="mt-2 flex min-w-0 flex-1 flex-col"> {/* Every text block is a fixed height and every line is clamped, so tiles
stay on a shared baseline no matter how long a title runs. */}
<div className="mt-2 flex min-w-0 flex-col">
<button onClick={onOpen} className="cursor-pointer text-left"> <button onClick={onOpen} className="cursor-pointer text-left">
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3> <h3
className="line-clamp-2 h-[2.25rem] text-[13px] font-medium leading-snug"
title={item.title}
>
{item.title}
</h3>
</button> </button>
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400"> <div
className="mt-1 h-4 truncate text-[12px] leading-4 text-slate-500 dark:text-slate-400"
title={item.channel_title}
>
{item.channel_title} {item.channel_title}
</div> </div>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500"> <div className="mt-0.5 h-4 truncate text-[11px] leading-4 text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")} {[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div> </div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && ( <div className="mt-2 flex h-7 items-start">
<div className="mt-1 line-clamp-2 text-[11px] text-red-600 dark:text-red-400">
{live?.error ?? item.error}
</div>
)}
<div className="mt-2 flex">
<DownloadButton item={item} live={live} online={online} <DownloadButton item={item} live={live} online={online}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} /> onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</div> </div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
<div
className="mt-1 line-clamp-1 text-[11px] text-red-600 dark:text-red-400"
title={live?.error ?? item.error ?? undefined}
>
{live?.error ?? item.error}
</div>
)}
</div> </div>
</li> </li>
); );
+30
View File
@@ -0,0 +1,30 @@
import type { FeedItem } from "../types";
/** Fraction of the video watched, or null if it was never opened. */
export function watchedFraction(item: FeedItem): number | null {
const { position, duration } = item;
if (position == null || duration == null || duration <= 0) return null;
return Math.min(1, Math.max(0, position / duration));
}
/**
* The red sliver across the bottom of a thumbnail, same idea as YouTube's.
* Red rather than the sky accent on purpose: the accent means "the action to
* take" or "currently selected", and this is neither.
*/
export default function WatchBar({ item }: { item: FeedItem }) {
const f = watchedFraction(item);
if (f == null || f < 0.01) return null;
const nearlyDone = f > 0.97;
return (
<span
className="absolute inset-x-0 bottom-0 h-[3px] bg-slate-950/45"
title={nearlyDone ? "Watched" : `${Math.round(f * 100)}% watched`}
>
<span
className="block h-full bg-red-600"
style={{ width: `${Math.max(2, f * 100)}%` }}
/>
</span>
);
}
+10
View File
@@ -43,6 +43,16 @@ export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 " + "text-[11px] text-slate-500 underline underline-offset-2 " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"; "hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/** The only spinning thing in the app; used where a wait has no known length. */
export function Spinner({ className = "size-4" }: { className?: string }) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function SectionHeading({ export function SectionHeading({
step, step,
children, children,
+5
View File
@@ -32,6 +32,9 @@ export interface FeedItem {
path: string | null; path: string | null;
pct: number | null; pct: number | null;
error: string | null; error: string | null;
/** Seconds watched, and the video's length — drives the feed progress bar. */
position: number | null;
duration: number | null;
} }
export interface FeedFilter { export interface FeedFilter {
@@ -82,3 +85,5 @@ export interface ImportPreview {
removed_videos: number; removed_videos: number;
removed_downloads: number; removed_downloads: number;
} }
export type Quality = "best" | "compatible";