Files
FlightTube/src/App.tsx
T
vincent 9bb7b71225 feat: uniform control height, chrome auto-hide, streaming quality
Every control in the app now shares one height (CONTROL_H), with width
still growing to fit its label. The player transport is larger, Back is
an icon, and the footer buttons match the prev/next size.

Player chrome — transport and edge arrows — fades after 2.6s of
inactivity and never hides while paused.

The title-bar strip collapses in macOS window fullscreen, where the
traffic lights are gone and it was just a blank white bar.

Streaming quality is now selectable alongside download quality. A cap is
applied by rewriting YouTube's HLS master playlist down to the best
variant at or below the chosen height, keeping its audio group. That
playlist is served from a small loopback HTTP server: Safari's native
HLS is backed by AVFoundation, which cannot read blob: or custom-scheme
URLs, so a Blob URL silently fails to play.

Settings closes with an icon button and its selects match the shared
control height.
2026-08-29 11:28:05 +02:00

403 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds,
} from "./api";
import Player from "./components/Player";
import Settings from "./components/Settings";
import Sidebar from "./components/Sidebar";
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";
import { useFeed } from "./hooks/useFeed";
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import {
QUALITIES, STREAM_QUALITIES,
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
} from "./types";
const TOAST_MS = 2400;
/** How often to pull new videos while online, so the feed stays live. */
const AUTO_REFRESH_MS = 10 * 60 * 1000;
function remembered(key: string): boolean {
try {
return localStorage.getItem(`flighttube.${key}`) === "1";
} catch {
return false;
}
}
export default function App() {
const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => {
try {
return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list";
} catch {
return "list";
}
});
const [showSettings, setShowSettings] = useState(false);
// Index into the current feed, so the player can step through it.
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
try {
const stored = localStorage.getItem("flighttube.streamQuality");
return STREAM_QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
} catch {
return "best";
}
});
const [quality, setQuality] = useState<Quality>(() => {
try {
const stored = localStorage.getItem("flighttube.quality");
return QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
} catch {
return "best";
}
});
const [refreshing, setRefreshing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const { mode, setMode } = useAppearance();
const windowFullscreen = useWindowFullscreen();
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
// A toast reports success and fades; a modal reports a failure or asks a
// question. Never make someone dismiss a box to be told it worked.
const toastTimer = useRef<number | undefined>(undefined);
const say = useCallback((message: string) => {
setToast(message);
window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), TOAST_MS);
}, []);
useEffect(() => () => window.clearTimeout(toastTimer.current), []);
useEffect(() => {
try {
localStorage.setItem("flighttube.view", view);
localStorage.setItem("flighttube.quality", quality);
localStorage.setItem("flighttube.streamQuality", streamQuality);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch {
/* storage blocked */
}
}, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]);
// 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;
const filter: FeedFilter = useMemo(
() => ({
channel_id: channelId,
search: search.trim() || null,
downloaded_only: effectiveDownloadedOnly,
hide_shorts: hideShorts,
limit: 500,
}),
[channelId, search, effectiveDownloadedOnly, hideShorts],
);
const { items, channels, loading, error, reload } = useFeed(filter);
const { live, clear: clearLive } = useDownloads(reload);
useEffect(() => {
let un: (() => void) | undefined;
onRefreshProgress(setRefreshProgress).then((u) => (un = u));
return () => un?.();
}, []);
useEffect(() => {
if (error) setFailure(error);
}, [error]);
const totals = useMemo(
() =>
channels.reduce(
(acc, c) => ({
videos: acc.videos + c.video_count,
downloaded: acc.downloaded + c.downloaded_count,
}),
{ videos: 0, downloaded: 0 },
),
[channels],
);
const doRefresh = useCallback(async () => {
setRefreshing(true);
try {
const s = await refreshFeeds();
await reload();
const failed = s.failures.length;
say(
failed
? `Checked ${s.channels} channels · ${failed} failed`
: `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
);
} catch (e) {
setFailure(String(e));
} finally {
setRefreshing(false);
setRefreshProgress(null);
}
}, [reload, say]);
// Downloaded plays from disk; anything else streams. Only being offline with
// no local copy leaves nothing to play.
const playableAt = useCallback(
(i: number): { item: FeedItem; path: string | null } | null => {
const item = items[i];
if (!item) return null;
const path = live[item.id]?.path ?? item.path;
const done = (live[item.id]?.state ?? item.state) === "done";
if (done && path) return { item, path };
return online ? { item, path: null } : null;
},
[items, live, online],
);
const openIndex = useCallback(
(i: number) => {
if (playableAt(i)) setPlayingIndex(i);
else setFailure("That video isn't downloaded, and you're offline.");
},
[playableAt],
);
/** Next/previous item that can actually be played right now. */
const stepFrom = useCallback(
(from: number, dir: 1 | -1): number | null => {
for (let i = from + dir; i >= 0 && i < items.length; i += dir) {
if (playableAt(i)) return i;
}
return null;
},
[items.length, playableAt],
);
const playing = playingIndex == null ? null : playableAt(playingIndex);
// The feed can change under an open player (a refresh, a filter change).
useEffect(() => {
if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null);
}, [items, playingIndex]);
// Keep the feed live while online. Skipped whenever a refresh is already
// running or the player is open, so it never yanks the list under you.
useEffect(() => {
if (!online) return;
const id = setInterval(() => {
if (!refreshing && playingIndex == null) void doRefresh();
}, AUTO_REFRESH_MS);
return () => clearInterval(id);
}, [online, refreshing, playingIndex, doRefresh]);
const emptyMessage = () => {
if (loading) return "Loading…";
if (channels.length === 0)
return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout.";
if (totals.videos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
if (!online) return "You're offline, and nothing has been downloaded yet.";
if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
return "Nothing matches this filter.";
};
return (
<div className="flex h-screen flex-col">
{/* The webview paints the title bar itself. It sits directly above the
sidebar and top bar, so it takes the panel colour, not the page's.
In macOS window fullscreen the traffic lights are gone, so the strip
would just be a blank bar — it collapses instead. */}
{!windowFullscreen && (
<div
data-tauri-drag-region
className="h-9 shrink-0 bg-white dark:bg-slate-900"
/>
)}
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
{/* With the sidebar hidden, a thin strip along the left edge brings it
back on hover. */}
{sidebarHidden && (
<div
onMouseEnter={() => setSidebarPeek(true)}
className="absolute inset-y-0 left-0 z-20 w-3"
aria-hidden
/>
)}
{sidebarHidden && sidebarPeek && (
<div
onMouseLeave={() => setSidebarPeek(false)}
className="absolute inset-y-0 left-0 z-30 w-[280px]"
>
<Sidebar
floating
channels={channels}
activeChannel={channelId}
onSelect={(id) => { setChannelId(id); setSidebarPeek(false); }}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }}
/>
</div>
)}
{!sidebarHidden && (
<Sidebar
channels={channels}
activeChannel={channelId}
onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => setSidebarHidden(true)}
/>
)}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar
search={search} onSearch={setSearch}
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
hideShorts={hideShorts} onHideShorts={setHideShorts}
online={online} reachable={reachable} forcedOffline={forcedOffline}
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length}
view={view} onView={setView}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
/>
{!online && (
<div
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
leading-snug text-amber-700 dark:text-amber-300"
>
Offline showing only videos you've downloaded.
{forcedOffline && " Offline mode is forced on."}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
{items.length === 0 ? (
<div className="grid h-full place-items-center">
<p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
{emptyMessage()}
</p>
</div>
) : (
<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, idx) => {
const shared = {
item,
live: live[item.id],
online,
onOpen: () => openIndex(idx),
onDownload: () =>
downloadVideo(item.id, quality).catch((e) => setFailure(String(e))),
onCancel: () =>
cancelDownload(item.id).catch((e) => setFailure(String(e))),
onDelete: () =>
deleteDownload(item.id)
.then(() => { clearLive(item.id); reload(); say("Download deleted"); })
.catch((e) => setFailure(String(e))),
};
return view === "grid" ? (
<VideoTile key={item.id} {...shared} />
) : (
<VideoRow key={item.id} {...shared} />
);
})}
</ul>
)}
</div>
</main>
</div>
{playing && playingIndex != null && (
<Player
key={playing.item.id}
item={playing.item}
path={playing.path}
index={playingIndex}
total={items.length}
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
onPrev={
stepFrom(playingIndex, -1) != null
? () => setPlayingIndex(stepFrom(playingIndex, -1))
: undefined
}
onNext={
stepFrom(playingIndex, 1) != null
? () => setPlayingIndex(stepFrom(playingIndex, 1))
: undefined
}
onDownload={
playing.path === null
? () => {
downloadVideo(playing.item.id, quality).catch((e) => setFailure(String(e)));
say("Download started");
}
: undefined
}
downloading={["queued", "running"].includes(
live[playing.item.id]?.state ?? playing.item.state ?? "",
)}
onClose={() => { setPlayingIndex(null); reload(); }}
onDelete={async () => {
await deleteDownload(playing.item.id);
clearLive(playing.item.id);
setPlayingIndex(null);
reload();
say("Download deleted");
}}
/>
)}
{showSettings && (
<Settings
onClose={() => setShowSettings(false)}
appearance={mode}
onAppearance={setMode}
quality={quality}
onQuality={setQuality}
streamQuality={streamQuality}
onStreamQuality={setStreamQuality}
onError={setFailure}
onImported={(n) => {
reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}}
/>
)}
{failure && (
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
<p className="break-words">{failure}</p>
</Dialog>
)}
<Toast message={toast} />
</div>
);
}