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}
+4
View File
@@ -33,6 +33,10 @@ export const deleteDownload = (videoId: string) =>
export const getConnectivity = () => invoke<boolean>("get_connectivity");
/** A directly playable URL (HLS master playlist) for an undownloaded video. */
export const resolveStream = (videoId: string) =>
invoke<string>("resolve_stream", { videoId });
export const setLibraryPath = (path: string) =>
invoke<string>("set_library_path", { path });
+77 -13
View File
@@ -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<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(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 (
<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" />
<header
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900"
@@ -27,26 +57,60 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</span>
<button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download
</button>
{streaming ? (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{src ? "Streaming" : error ? "Unavailable" : "Resolving…"}
</span>
) : (
<button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download
</button>
)}
</header>
{/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */}
<div className="relative min-h-0 flex-1 bg-slate-950">
<video key={path} src={fileUrl(path)} controls autoPlay
className="absolute inset-0 size-full object-contain" />
{src ? (
<video key={src} src={src} controls autoPlay
className="absolute inset-0 size-full object-contain" />
) : (
<div className="absolute inset-0 grid place-items-center px-6 text-center">
{error ? (
<div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p>
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} mt-2 cursor-pointer`}
>
Open on YouTube instead
</button>
</div>
) : (
<p className="text-[12px] text-slate-400">Finding a stream</p>
)}
</div>
)}
</div>
<footer
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
dark:border-slate-800 dark:bg-slate-900"
>
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
<div className="flex items-start justify-between gap-3">
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
{streaming && (
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
>
Open on YouTube
</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>
+1 -1
View File
@@ -23,7 +23,7 @@ export default function Sidebar({
return (
<aside
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
border-slate-300 bg-white lg:h-screen lg:max-h-none lg:w-[280px]
border-slate-300 bg-white lg:h-full lg:max-h-none lg:w-[280px]
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
>
<header
+15 -2
View File
@@ -1,4 +1,6 @@
import { BTN_PRIMARY, INPUT } from "./ui";
import { BTN_PRIMARY, INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid";
interface Props {
search: string;
@@ -15,6 +17,8 @@ interface Props {
refreshing: boolean;
refreshProgress: { done: number; total: number } | null;
resultCount: number;
view: ViewMode;
onView: (v: ViewMode) => void;
}
/** Neutral outline until active; active is the one filled state. */
@@ -49,7 +53,7 @@ function Toggle({
export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
@@ -85,6 +89,15 @@ export default function TopBar({
Hide Shorts
</Toggle>
<Segmented
value={view}
onChange={onView}
options={[
{ value: "list", label: "List" },
{ value: "grid", label: "Tiles" },
]}
/>
<button
onClick={onToggleForcedOffline}
title={
+80
View File
@@ -0,0 +1,80 @@
import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton";
import { compactViews, relativeTime } from "./format";
interface Props {
item: FeedItem;
live?: LiveDownload;
online: boolean;
onOpen: () => void;
onDownload: () => void;
onCancel: () => void;
onDelete: () => void;
}
/** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */
export default function VideoTile({
item, live, online, onOpen, onDownload, onCancel, onDelete,
}: Props) {
const downloaded = (live?.state ?? item.state) === "done";
const src = thumbSrc(item, online);
return (
<li className="group flex flex-col">
<button
onClick={onOpen}
className="relative aspect-video w-full cursor-pointer overflow-hidden rounded-lg
border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-800"
>
{src ? (
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
) : (
<span className="grid size-full place-items-center px-2 text-center text-[10px] text-slate-400">
No thumbnail
</span>
)}
{item.is_short && (
<span
className="absolute left-1.5 top-1.5 rounded bg-slate-950/75 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Short
</span>
)}
{downloaded && (
<span
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Offline
</span>
)}
</button>
<div className="mt-2 flex min-w-0 flex-1 flex-col">
<button onClick={onOpen} className="cursor-pointer text-left">
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
</button>
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</div>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
<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}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</div>
</div>
</li>
);
}