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. */}
-