From f47bc3dab13cf9965fe87bd8e28b0433bb369533 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 03:24:24 +0200 Subject: [PATCH] feat: tile view, in-app streaming, and self-painted title bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an undownloaded video now plays it in the app instead of handing off to the browser. YouTube's iframe embed cannot be used — it rejects a tauri:// origin with Error 153 — so yt-dlp resolves YouTube's HLS master playlist instead, whose H.264+AAC variants AVFoundation streams natively in WKWebView, adaptive up to 1080p. Adds an optional Tiles view alongside the list, remembered between launches. The title bar is now transparent with the title hidden, and the app paints that strip itself in the page background so it matches instead of showing macOS chrome beside the traffic lights. --- src-tauri/src/commands.rs | 55 ++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 4 +- src/App.tsx | 78 ++++++++++++++++++++++++------- src/api.ts | 4 ++ src/components/Player.tsx | 90 ++++++++++++++++++++++++++++++------ src/components/Sidebar.tsx | 2 +- src/components/TopBar.tsx | 17 ++++++- src/components/VideoTile.tsx | 80 ++++++++++++++++++++++++++++++++ 9 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 src/components/VideoTile.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 93a7db4..6ef4017 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -165,6 +165,61 @@ pub async fn import_takeout_csv( db.replace_channels(&channels) } +/// Resolves a directly playable URL for a video we have NOT downloaded, so it +/// can stream inside the app's own player. +/// +/// YouTube's iframe embed is not an option here: it rejects a Tauri window with +/// "Error 153" because the page origin is `tauri://localhost` rather than an +/// http(s) origin it will accept. Instead we ask yt-dlp for YouTube's HLS master +/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio +/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with +/// adaptive bitrate for free. +#[tauri::command] +pub async fn resolve_stream(video_id: String) -> Result { + let url = format!("https://www.youtube.com/watch?v={video_id}"); + + // The HLS master playlist. Every m3u8 format shares the same manifest_url, + // so any one of them yields the master. + if let Some(u) = yt_dlp_print( + &["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url], + ) + .await + { + return Ok(u); + } + + // Rare fallback: an old-style progressive muxed MP4. + if let Some(u) = yt_dlp_print(&[ + "-f", + "b[ext=mp4][acodec!=none][vcodec!=none]", + "--print", + "%(url)s", + &url, + ]) + .await + { + return Ok(u); + } + + Err("Could not find a playable stream for this video.".into()) +} + +/// Runs yt-dlp and returns its first non-empty stdout line, or None. +async fn yt_dlp_print(args: &[&str]) -> Option { + let mut cmd = tokio::process::Command::new(bin("yt-dlp")); + cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); + cmd.args(args); + let out = cmd.output().await.ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .find(|l| l.starts_with("http")) + .map(str::to_string) +} + #[tauri::command] pub async fn list_channels(state: State<'_, AppState>) -> Result, String> { state.db.lock().await.list_channels() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae6b81d..7e6e73f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub fn run() { commands::preview_takeout_import, commands::list_channels, commands::list_feed, + commands::resolve_stream, commands::refresh_feeds, commands::download_video, commands::cancel_download, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 770ca1a..e744069 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,7 +17,9 @@ "height": 780, "minWidth": 900, "minHeight": 560, - "center": true + "center": true, + "titleBarStyle": "Transparent", + "hiddenTitle": true } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 2c0f1b8..546d102 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, - openExternal, refreshFeeds, + cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds, } from "./api"; import Player from "./components/Player"; import Settings from "./components/Settings"; import Sidebar from "./components/Sidebar"; -import TopBar from "./components/TopBar"; +import TopBar, { type ViewMode } from "./components/TopBar"; import { Dialog, Toast } from "./components/ui"; import VideoRow from "./components/VideoRow"; +import VideoTile from "./components/VideoTile"; import { useAppearance } from "./hooks/useAppearance"; import { useConnectivity } from "./hooks/useConnectivity"; import { useDownloads } from "./hooks/useDownloads"; @@ -22,8 +22,15 @@ export default function App() { const [search, setSearch] = useState(""); const [downloadedOnly, setDownloadedOnly] = useState(false); const [hideShorts, setHideShorts] = useState(false); + const [view, setView] = useState(() => { + try { + return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list"; + } catch { + return "list"; + } + }); const [showSettings, setShowSettings] = useState(false); - const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null); + const [playing, setPlaying] = useState<{ item: FeedItem; path: string | null } | null>(null); const [refreshing, setRefreshing] = useState(false); const [refreshProgress, setRefreshProgress] = useState(null); const [toast, setToast] = useState(null); @@ -42,6 +49,14 @@ export default function App() { }, []); useEffect(() => () => window.clearTimeout(toastTimer.current), []); + useEffect(() => { + try { + localStorage.setItem("flighttube.view", view); + } catch { + /* storage blocked */ + } + }, [view]); + // Offline, the only videos that can be played are the ones already on disk, // so the feed collapses to those regardless of the toggle. const effectiveDownloadedOnly = downloadedOnly || !online; @@ -102,13 +117,15 @@ export default function App() { }, [reload, say]); const openItem = useCallback( - async (item: FeedItem) => { + (item: FeedItem) => { const path = live[item.id]?.path ?? item.path; const done = (live[item.id]?.state ?? item.state) === "done"; + // Downloaded plays from disk; anything else streams YouTube's embed in + // the app. Only being offline with no local copy leaves nothing to play. if (done && path) { setPlaying({ item, path }); } else if (online) { - await openExternal(`https://www.youtube.com/watch?v=${item.id}`); + setPlaying({ item, path: null }); } else { setFailure("That video isn't downloaded, and you're offline."); } @@ -127,7 +144,16 @@ export default function App() { }; return ( -
+
+ {/* The title bar is transparent, so the webview paints this strip itself + in the page background — the macOS traffic lights sit on top of it. + Without the drag region the strip would be dead space. */} +
+ +
{ setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} resultCount={items.length} + view={view} onView={setView} /> {!online && ( @@ -166,22 +193,39 @@ export default function App() {

) : ( -
    - {items.map((item) => ( - openItem(item)} - onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))} - onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))} - onDelete={() => +
      + {items.map((item) => { + const shared = { + item, + live: live[item.id], + online, + onOpen: () => openItem(item), + onDownload: () => + downloadVideo(item.id).catch((e) => setFailure(String(e))), + onCancel: () => + cancelDownload(item.id).catch((e) => setFailure(String(e))), + onDelete: () => deleteDownload(item.id) .then(() => { reload(); say("Download deleted"); }) - .catch((e) => setFailure(String(e))) - } /> - ))} + .catch((e) => setFailure(String(e))), + }; + return view === "grid" ? ( + + ) : ( + + ); + })}
    )}
+
{playing && ( export const getConnectivity = () => invoke("get_connectivity"); +/** A directly playable URL (HLS master playlist) for an undownloaded video. */ +export const resolveStream = (videoId: string) => + invoke("resolve_stream", { videoId }); + export const setLibraryPath = (path: string) => invoke("set_library_path", { path }); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 5c1bef9..bc9caf9 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,22 +1,52 @@ -import { fileUrl } from "../api"; +import { useEffect, useState } from "react"; +import { fileUrl, openExternal, resolveStream } from "../api"; import type { FeedItem } from "../types"; import { compactViews, relativeTime } from "./format"; -import { BTN, BTN_CHROME } from "./ui"; +import { BTN, BTN_CHROME, BTN_QUIET } from "./ui"; interface Props { item: FeedItem; - path: string; + /** Local file when downloaded; null means resolve and stream it instead. */ + path: string | null; onClose: () => void; onDelete: () => void; } /** - * Plays the local file through Tauri's asset protocol. Every download is + * Two sources, one player element. + * + * Downloaded: the local file through Tauri's asset protocol. Every download is * H.264/AAC in MP4 precisely so WKWebView can decode it natively. + * + * Not downloaded: YouTube's HLS master playlist, resolved by yt-dlp. Its + * variants are H.264 + AAC up to 1080p, which AVFoundation streams natively — + * so watching still happens here rather than in a browser. The iframe embed + * cannot be used: it rejects a `tauri://` origin with "Error 153". */ export default function Player({ item, path, onClose, onDelete }: Props) { + const streaming = path === null; + const [src, setSrc] = useState(path ? fileUrl(path) : null); + const [error, setError] = useState(null); + + useEffect(() => { + if (path) { + setSrc(fileUrl(path)); + return; + } + let cancelled = false; + setSrc(null); + setError(null); + resolveStream(item.id) + .then((u) => !cancelled && setSrc(u)) + .catch((e) => !cancelled && setError(String(e))); + return () => { + cancelled = true; + }; + }, [item.id, path]); + return (
+
{item.channel_title} - + {streaming ? ( + + {src ? "Streaming" : error ? "Unavailable" : "Resolving…"} + + ) : ( + + )}
{/* Absolute fill + object-contain, so portrait Shorts and landscape videos are both letterboxed to the pane instead of overflowing it. */}
-