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:
+61
-17
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user