feat: tile view, in-app streaming, and self-painted title bar

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.
This commit is contained in:
vincent
2026-08-29 03:24:24 +02:00
parent ccb25fad33
commit f47bc3dab1
9 changed files with 297 additions and 34 deletions
+61 -17
View File
@@ -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<ViewMode>(() => {
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<RefreshProgress | null>(null);
const [toast, setToast] = useState<string | null>(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 (
<div className="flex h-screen flex-col lg:flex-row">
<div className="flex h-screen flex-col">
{/* 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. */}
<div
data-tauri-drag-region
className="h-9 shrink-0 bg-slate-100 dark:bg-slate-950"
/>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<Sidebar
channels={channels}
activeChannel={channelId}
@@ -146,6 +172,7 @@ export default function App() {
onToggleForcedOffline={() => { 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() {
</p>
</div>
) : (
<ul className="mx-auto max-w-4xl space-y-1.5">
{items.map((item) => (
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
onOpen={() => openItem(item)}
onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))}
onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))}
onDelete={() =>
<ul
className={
view === "grid"
? "mx-auto grid max-w-7xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-4 gap-y-6"
: "mx-auto max-w-4xl space-y-1.5"
}
>
{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" ? (
<VideoTile key={item.id} {...shared} />
) : (
<VideoRow key={item.id} {...shared} />
);
})}
</ul>
)}
</div>
</main>
</div>
{playing && (
<Player item={playing.item} path={playing.path}