fix: ffmpeg version detection and player letterboxing
ffmpeg only accepts -version (single dash); it exits 8 on --version and writes to stderr, so the status panel reported it missing even when installed. Version flags are now per-tool and both streams are read. The player used max-h-full inside a grid, which a 1080x1920 Short overflowed. Switched to absolute fill with object-contain so portrait and landscape both letterbox correctly. Also sized the window to 1180x780 centered, which fits a laptop display.
This commit is contained in:
@@ -1,7 +1,100 @@
|
|||||||
# Tauri + React + Typescript
|
# FlightTube
|
||||||
|
|
||||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
A desktop app that merges all your YouTube subscriptions into one newest-first feed,
|
||||||
|
downloads the videos you pick, and — when you have no connection — shows only what you
|
||||||
|
already downloaded. Built for the flight.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
## How it works
|
||||||
|
|
||||||
|
**Subscriptions come from Google Takeout, not the API.** Export *YouTube subscriptions*
|
||||||
|
from [Takeout](https://takeout.google.com/) and import the `subscriptions.csv` in
|
||||||
|
Settings. No OAuth, no API key, no Google Cloud project, nothing secret on disk.
|
||||||
|
|
||||||
|
**Video metadata comes from YouTube's public Atom feed** — one request per channel to
|
||||||
|
`youtube.com/feeds/videos.xml?channel_id=…`, fetched 8 at a time and merged into a single
|
||||||
|
feed sorted by publish date. Thumbnails are mirrored to local disk so the feed still
|
||||||
|
renders with no network.
|
||||||
|
|
||||||
|
**Downloads shell out to `yt-dlp`,** pinned to H.264 video + AAC audio in an MP4
|
||||||
|
container. That caps quality at 1080p — YouTube only serves H.264 that high, and anything
|
||||||
|
above it is VP9 or AV1, which the app's own player cannot reliably decode. The tradeoff is
|
||||||
|
deliberate: every download is guaranteed to play inside FlightTube.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install yt-dlp ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are checked at startup; Settings shows their versions and this command if either is
|
||||||
|
missing. `ffmpeg` is required because best-quality H.264 and AAC arrive as separate
|
||||||
|
streams that must be merged.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install && npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a real app bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. **Settings → Import subscriptions.csv** — point it at your Takeout export.
|
||||||
|
2. **Refresh** — pulls the latest videos from every channel.
|
||||||
|
3. **Download** on any video — progress shows live on the button; click again to cancel.
|
||||||
|
4. Click a **downloaded** video to play it in the app. Click an undownloaded one to open
|
||||||
|
it on YouTube.
|
||||||
|
5. **Offline** — the app detects a lost connection and collapses the feed to your
|
||||||
|
downloads. The connectivity pill also toggles a forced offline mode for testing.
|
||||||
|
|
||||||
|
Videos land in `~/Movies/FlightTube` (changeable in Settings).
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- The Atom feed returns only the **~15 most recent videos per channel**. There is no
|
||||||
|
backfill and no pagination — this is a rolling recent window, not an archive.
|
||||||
|
- Quality tops out at 1080p, by the deliberate choice described above.
|
||||||
|
- `yt-dlp` needs occasional updating (`brew upgrade yt-dlp`) as YouTube changes.
|
||||||
|
- Downloading videos is contrary to YouTube's Terms of Service.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
37 unit tests cover the three places malformed input actually bites: Takeout CSV parsing,
|
||||||
|
Atom feed parsing (against a captured real response), and `yt-dlp` progress-line parsing —
|
||||||
|
plus the database rule that a refresh must never clobber download state.
|
||||||
|
|
||||||
|
Two network-dependent tests are excluded by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test pipeline -- --ignored --nocapture # full pipeline vs. live feeds
|
||||||
|
cargo test --test seed_real_db -- --ignored --nocapture # populate the installed app's db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/
|
||||||
|
takeout.rs subscriptions.csv -> channels (pure, tested)
|
||||||
|
feed.rs Atom XML -> videos, plus fetching (parse is pure, tested)
|
||||||
|
downloader.rs yt-dlp arguments and progress lines (pure, tested)
|
||||||
|
db.rs schema and every SQL statement
|
||||||
|
thumbs.rs local thumbnail cache
|
||||||
|
net.rs reachability probe
|
||||||
|
commands.rs Tauri command surface — delegates only
|
||||||
|
src/
|
||||||
|
components/ Sidebar, TopBar, VideoRow, DownloadButton, Player, Settings
|
||||||
|
hooks/ useFeed, useDownloads, useConnectivity
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes and the implementation plan are in `docs/superpowers/`.
|
||||||
|
|||||||
+19
-16
@@ -80,29 +80,32 @@ fn bin(name: &str) -> String {
|
|||||||
name.to_string()
|
name.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn version_of(name: &str) -> Option<String> {
|
/// `flag` differs per tool: yt-dlp takes `--version`, ffmpeg only accepts
|
||||||
std::process::Command::new(bin(name))
|
/// `-version` (it exits non-zero on `--version` and writes to stderr), so the
|
||||||
.arg("--version")
|
/// flag is passed in and both streams are consulted.
|
||||||
|
fn version_of(name: &str, flag: &str) -> Option<String> {
|
||||||
|
let out = std::process::Command::new(bin(name))
|
||||||
|
.arg(flag)
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()?;
|
||||||
.filter(|o| o.status.success())
|
if !out.status.success() {
|
||||||
.map(|o| {
|
return None;
|
||||||
String::from_utf8_lossy(&o.stdout)
|
}
|
||||||
.lines()
|
let text = if out.stdout.is_empty() {
|
||||||
.next()
|
String::from_utf8_lossy(&out.stderr).to_string()
|
||||||
.unwrap_or("")
|
} else {
|
||||||
.trim()
|
String::from_utf8_lossy(&out.stdout).to_string()
|
||||||
.to_string()
|
};
|
||||||
})
|
let first = text.lines().next().unwrap_or("").trim().to_string();
|
||||||
.filter(|s| !s.is_empty())
|
(!first.is_empty()).then_some(first)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
||||||
let library = state.library.lock().await.clone();
|
let library = state.library.lock().await.clone();
|
||||||
Ok(Prereqs {
|
Ok(Prereqs {
|
||||||
yt_dlp: version_of("yt-dlp"),
|
yt_dlp: version_of("yt-dlp", "--version"),
|
||||||
ffmpeg: version_of("ffmpeg").map(|v| {
|
ffmpeg: version_of("ffmpeg", "-version").map(|v| {
|
||||||
// ffmpeg's first line is long; keep the useful head of it.
|
// ffmpeg's first line is long; keep the useful head of it.
|
||||||
v.split_whitespace().take(3).collect::<Vec<_>>().join(" ")
|
v.split_whitespace().take(3).collect::<Vec<_>>().join(" ")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "FlightTube",
|
"title": "FlightTube",
|
||||||
"width": 1280,
|
"width": 1180,
|
||||||
"height": 840,
|
"height": 780,
|
||||||
"minWidth": 940,
|
"minWidth": 900,
|
||||||
"minHeight": 600
|
"minHeight": 560,
|
||||||
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Populates the installed app's database using the app's own code paths, so
|
||||||
|
//! the UI can be verified against real data without driving the file dialog.
|
||||||
|
//! Run with: cargo test --test seed_real_db -- --ignored --nocapture
|
||||||
|
|
||||||
|
use flighttube_lib::{db::Db, feed, models::FeedFilter, thumbs};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn app_data() -> PathBuf {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap())
|
||||||
|
.join("Library/Application Support/com.vincent.flighttube")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn seed() {
|
||||||
|
let dir = app_data();
|
||||||
|
let mut db = Db::open(&dir.join("flighttube.db")).expect("open real db");
|
||||||
|
|
||||||
|
let channels = db.channel_ids().unwrap();
|
||||||
|
println!("channels in db: {}", channels.len());
|
||||||
|
assert!(!channels.is_empty(), "seed channels first");
|
||||||
|
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent("FlightTube/0.1 (+desktop)")
|
||||||
|
.timeout(std::time::Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut total = 0;
|
||||||
|
for cid in &channels {
|
||||||
|
if let Ok(v) = feed::fetch_channel(&http, cid).await {
|
||||||
|
total += v.len();
|
||||||
|
db.upsert_videos(&v).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("videos upserted: {total}");
|
||||||
|
|
||||||
|
// Cache thumbnails so the feed renders with no network.
|
||||||
|
let pending = db.videos_missing_thumbs(400).unwrap();
|
||||||
|
println!("thumbnails to cache: {}", pending.len());
|
||||||
|
let tdir = thumbs::cache_dir(&dir);
|
||||||
|
let mut ok = 0;
|
||||||
|
for (id, url) in pending {
|
||||||
|
if let Ok(p) = thumbs::cache_one(&http, &id, &url, &tdir).await {
|
||||||
|
db.set_thumb_path(&id, &p.to_string_lossy()).unwrap();
|
||||||
|
ok += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("thumbnails cached: {ok}");
|
||||||
|
|
||||||
|
let feed_rows = db.list_feed(&FeedFilter::default()).unwrap();
|
||||||
|
println!("feed rows now: {}", feed_rows.len());
|
||||||
|
for i in feed_rows.iter().take(3) {
|
||||||
|
println!(" [{}] {}", i.channel_title, i.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,11 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 bg-black grid place-items-center">
|
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||||
|
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||||
|
<div className="flex-1 min-h-0 bg-black relative">
|
||||||
<video key={path} src={fileUrl(path)} controls autoPlay
|
<video key={path} src={fileUrl(path)} controls autoPlay
|
||||||
className="max-h-full max-w-full" />
|
className="absolute inset-0 h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge">
|
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge">
|
||||||
|
|||||||
Reference in New Issue
Block a user