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.
@@ -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 |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
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>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 994 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 560,
|
"minHeight": 560,
|
||||||
"center": true,
|
"center": true,
|
||||||
"titleBarStyle": "Transparent",
|
"titleBarStyle": "Overlay",
|
||||||
"hiddenTitle": true
|
"hiddenTitle": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||