feat: custom transport bar, live refresh, collapsible sidebar

Replace WebKit's native video controls with our own. WebKit puts
fullscreen, Picture-in-Picture and volume as overlay buttons in the
video's top corners with no way to move them; owning the bar is the only
way to get every control into one strip along the bottom. Right-click on
the player is suppressed — its menu acted on a video the app does not
control.

One spinner now covers both waits: resolving the stream URL and
buffering it. It no longer sticks after a resume-seek, which fires
'waiting' after 'playing'.

Also: Open on YouTube and a new Download are real buttons in the player;
refresh is a compact icon; Settings is a cog; the subscription sidebar
collapses and reappears on a left-edge hover; the feed auto-refreshes
every 10 minutes while online, skipped while the player is open; the
title-bar strip takes the panel colour, since it sits above panels
rather than the page; and downloaded-only, hide-Shorts and the sidebar
state are remembered between launches.
This commit is contained in:
vincent
2026-08-29 11:03:29 +02:00
parent 61db10b2c8
commit 211823f265
6 changed files with 448 additions and 56 deletions
+83 -16
View File
@@ -16,12 +16,24 @@ import { useFeed } from "./hooks/useFeed";
import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types"; import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types";
const TOAST_MS = 2400; 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() { export default function App() {
const [channelId, setChannelId] = useState<string | null>(null); const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(false); const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
const [hideShorts, setHideShorts] = useState(false); const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => { const [view, setView] = useState<ViewMode>(() => {
try { try {
return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list"; return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list";
@@ -62,10 +74,13 @@ export default function App() {
try { try {
localStorage.setItem("flighttube.view", view); localStorage.setItem("flighttube.view", view);
localStorage.setItem("flighttube.quality", quality); localStorage.setItem("flighttube.quality", quality);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch { } catch {
/* storage blocked */ /* storage blocked */
} }
}, [view, quality]); }, [view, quality, downloadedOnly, hideShorts, sidebarHidden]);
// Offline, the only videos that can be played are the ones already on disk, // Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle. // so the feed collapses to those regardless of the toggle.
@@ -165,6 +180,16 @@ export default function App() {
if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null); if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null);
}, [items, playingIndex]); }, [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 = () => { const emptyMessage = () => {
if (loading) return "Loading…"; if (loading) return "Loading…";
if (channels.length === 0) if (channels.length === 0)
@@ -177,23 +202,52 @@ export default function App() {
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
{/* The title bar is transparent, so the webview paints this strip itself {/* The webview paints the title bar itself. It sits directly above the
in the page background — the macOS traffic lights sit on top of it. sidebar and top bar, so it takes the panel colour, not the page's. */}
Without the drag region the strip would be dead space. */}
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-9 shrink-0 bg-slate-100 dark:bg-slate-950" className="h-9 shrink-0 bg-white dark:bg-slate-900"
/> />
<div className="flex min-h-0 flex-1 flex-col lg:flex-row"> <div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
<Sidebar {/* With the sidebar hidden, a thin strip along the left edge brings it
channels={channels} back on hover. */}
activeChannel={channelId} {sidebarHidden && (
onSelect={setChannelId} <div
onOpenSettings={() => setShowSettings(true)} onMouseEnter={() => setSidebarPeek(true)}
totalVideos={totals.videos} className="absolute inset-y-0 left-0 z-20 w-3"
totalDownloaded={totals.downloaded} 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"> <main className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar <TopBar
@@ -205,6 +259,8 @@ export default function App() {
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length} resultCount={items.length}
view={view} onView={setView} view={view} onView={setView}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
/> />
{!online && ( {!online && (
@@ -276,6 +332,17 @@ export default function App() {
? () => setPlayingIndex(stepFrom(playingIndex, 1)) ? () => setPlayingIndex(stepFrom(playingIndex, 1))
: undefined : 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(); }} onClose={() => { setPlayingIndex(null); reload(); }}
onDelete={async () => { onDelete={async () => {
await deleteDownload(playing.item.id); await deleteDownload(playing.item.id);
+58 -26
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api"; import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui"; import PlayerControls from "./PlayerControls";
import { BTN, BTN_CHROME, Spinner } from "./ui";
interface Props { interface Props {
item: FeedItem; item: FeedItem;
@@ -12,6 +13,9 @@ interface Props {
onDelete: () => void; onDelete: () => void;
onPrev?: () => void; onPrev?: () => void;
onNext?: () => void; onNext?: () => void;
/** Present only while streaming, so the video can be saved from here. */
onDownload?: () => void;
downloading?: boolean;
/** Position in the current feed, for the "3 of 180" readout. */ /** Position in the current feed, for the "3 of 180" readout. */
index: number; index: number;
total: number; total: number;
@@ -88,12 +92,12 @@ const RESUME_EDGE_S = 5;
* cannot be used: it rejects a `tauri://` origin with "Error 153". * cannot be used: it rejects a `tauri://` origin with "Error 153".
*/ */
export default function Player({ export default function Player({
item, path, onClose, onDelete, onPrev, onNext, index, total, item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total,
}: Props) { }: Props) {
const streaming = path === null; const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null); const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buffering, setBuffering] = useState(false); const [buffering, setBuffering] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0); const lastSave = useRef(0);
@@ -133,6 +137,12 @@ export default function Player({
}, [src]); }, [src]);
const onTimeUpdate = () => { const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not
// buffering. A resume-seek can fire `waiting` after `playing` and leave the
// spinner stuck otherwise.
const v = videoRef.current;
if (v && v.readyState >= 3 && !v.paused) setBuffering(false);
const now = Date.now(); const now = Date.now();
if (now - lastSave.current < SAVE_EVERY_MS) return; if (now - lastSave.current < SAVE_EVERY_MS) return;
lastSave.current = now; lastSave.current = now;
@@ -152,7 +162,9 @@ export default function Player({
// Escape backs out, as it does everywhere else in the app. // Escape backs out, as it does everywhere else in the app.
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") leave(); // In fullscreen the browser already handles Escape; closing the player
// as well would drop you all the way back to the feed.
if (e.key === "Escape" && !document.fullscreenElement) leave();
// Arrow keys only when the video does not own them for seeking. // Arrow keys only when the video does not own them for seeking.
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.(); if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
if (e.key === "ArrowRight" && e.shiftKey) onNext?.(); if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
@@ -214,7 +226,11 @@ export default function Player({
{/* Absolute fill + object-contain, so portrait Shorts and landscape {/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */} videos are both letterboxed to the pane instead of overflowing it. */}
<div ref={stageRef} className="group/stage relative min-h-0 flex-1 bg-slate-950"> <div
ref={stageRef}
onContextMenu={(e) => e.preventDefault()}
className="group/stage relative min-h-0 flex-1 bg-slate-950"
>
{/* Edge arrows, the way a player wants them: big targets on the left and {/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top right of the picture. They fade in on hover so they never sit on top
of the video while you are watching it. */} of the video while you are watching it. */}
@@ -237,10 +253,10 @@ export default function Player({
</button> </button>
{/* Two different waits look the same to you: resolving the stream, and {/* Resolving the stream and buffering it are the same wait as far as
the player buffering it. Both get the spinner. */} you are concerned, so they get the same spinner in the same place. */}
{src && buffering && ( {(!src || buffering) && !error && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center"> <div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
<Spinner className="size-8 text-white/80" /> <Spinner className="size-8 text-white/80" />
</div> </div>
)} )}
@@ -250,8 +266,14 @@ export default function Player({
ref={videoRef} ref={videoRef}
key={src} key={src}
src={src} src={src}
controls
autoPlay autoPlay
onContextMenu={(e) => e.preventDefault()}
onClick={() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => {});
else v.pause();
}}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onPause={persist} onPause={persist}
@@ -260,27 +282,26 @@ export default function Player({
onStalled={() => setBuffering(true)} onStalled={() => setBuffering(true)}
onCanPlay={() => setBuffering(false)} onCanPlay={() => setBuffering(false)}
onPlaying={() => setBuffering(false)} onPlaying={() => setBuffering(false)}
onSeeked={() => setBuffering(false)}
className="absolute inset-0 size-full object-contain" className="absolute inset-0 size-full object-contain"
/> />
) : ( ) : (
<div className="absolute inset-0 grid place-items-center px-6 text-center"> error && (
{error ? ( <div className="absolute inset-0 grid place-items-center px-6 text-center">
<div className="max-w-sm"> <div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p> <p className="text-[13px] text-red-400">{error}</p>
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} mt-2 cursor-pointer`} className={`${BTN} mt-3 cursor-pointer py-1.5`}
> >
Open on YouTube instead Open on YouTube instead
</button> </button>
</div> </div>
) : ( </div>
<div className="flex items-center gap-2 text-slate-400"> )
<Spinner /> )}
<span className="text-[12px]">Finding a stream</span> {src && !error && (
</div> <PlayerControls videoRef={videoRef} stageRef={stageRef} />
)}
</div>
)} )}
</div> </div>
@@ -297,12 +318,23 @@ export default function Player({
.join(" · ")} .join(" · ")}
</div> </div>
</div> </div>
<button <div className="flex shrink-0 items-center gap-2">
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} {onDownload && (
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`} <button
> onClick={onDownload}
Open on YouTube disabled={downloading}
</button> className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
>
{downloading ? "Downloading…" : "Download"}
</button>
)}
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
>
Open on YouTube
</button>
</div>
</div> </div>
{/* Collapsed by default — the description is rarely what you came for. */} {/* Collapsed by default — the description is rarely what you came for. */}
+233
View File
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useState } from "react";
interface Props {
videoRef: React.RefObject<HTMLVideoElement | null>;
/** Element to fullscreen — the stage, so letterboxing travels with it. */
stageRef: React.RefObject<HTMLDivElement | null>;
/** Nudges the auto-hide timer whenever the user does something. */
onActivity?: () => void;
}
const SKIP_S = 10;
function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
}
const btn =
"grid size-8 shrink-0 place-items-center rounded-md text-white/90 cursor-pointer " +
"hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed";
/**
* Our own transport bar.
*
* WebKit's native controls put fullscreen, Picture-in-Picture and volume as
* overlay buttons in the top corners of the video and give no way to move them.
* Owning the bar is the only way to get every control into one strip along the
* bottom, so the native ones are switched off entirely.
*/
export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) {
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [pip, setPip] = useState(false);
const [full, setFull] = useState(false);
// Mirror the element's state rather than assuming ours is authoritative —
// playback can change from the keyboard, the system, or the video ending.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const sync = () => {
setPlaying(!v.paused);
setTime(v.currentTime);
setDuration(Number.isFinite(v.duration) ? v.duration : 0);
setVolume(v.volume);
setMuted(v.muted);
};
const onPip = () => setPip(!!document.pictureInPictureElement);
const onFull = () => setFull(!!document.fullscreenElement);
sync();
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
v.addEventListener(e, sync);
}
v.addEventListener("enterpictureinpicture", onPip);
v.addEventListener("leavepictureinpicture", onPip);
document.addEventListener("fullscreenchange", onFull);
return () => {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
v.removeEventListener(e, sync);
}
v.removeEventListener("enterpictureinpicture", onPip);
v.removeEventListener("leavepictureinpicture", onPip);
document.removeEventListener("fullscreenchange", onFull);
};
}, [videoRef]);
const act = useCallback(
(fn: (v: HTMLVideoElement) => void) => () => {
const v = videoRef.current;
if (v) fn(v);
onActivity?.();
},
[videoRef, onActivity],
);
const togglePlay = act((v) => {
if (v.paused) void v.play().catch(() => {});
else v.pause();
});
const togglePip = useCallback(async () => {
const v = videoRef.current;
if (!v) return;
onActivity?.();
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else await v.requestPictureInPicture();
} catch {
/* not available for this source */
}
}, [videoRef, onActivity]);
const toggleFull = useCallback(async () => {
onActivity?.();
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await stageRef.current?.requestFullscreen();
} catch {
/* element fullscreen unavailable */
}
}, [stageRef, onActivity]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
return (
<div
// Clicks here must not reach the video's own play/pause handler.
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2 bg-gradient-to-t
from-slate-950/85 via-slate-950/60 to-transparent px-4 pb-3 pt-8"
>
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
{playing ? (
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
<path d="M8 5.5v13l11-6.5z" />
</svg>
)}
</button>
<button
onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))}
className={btn}
title={`Back ${SKIP_S}s`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
</svg>
</button>
<button
onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))}
className={btn}
title={`Forward ${SKIP_S}s`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg>
</button>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(time)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={time}
onChange={(e) => {
const v = videoRef.current;
if (v) v.currentTime = Number(e.target.value);
onActivity?.();
}}
aria-label="Seek"
className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
style={{
background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`,
}}
/>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(duration)}</span>
<button
onClick={act((v) => (v.muted = !v.muted))}
className={btn}
title={muted || volume === 0 ? "Unmute" : "Mute"}
>
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
<path d="M4 9v6h4l5 4V5L8 9H4z" />
{muted || volume === 0 ? (
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
) : (
<path
d="M16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
)}
</svg>
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
value={muted ? 0 : volume}
onChange={(e) => {
const v = videoRef.current;
if (!v) return;
v.volume = Number(e.target.value);
v.muted = Number(e.target.value) === 0;
onActivity?.();
}}
aria-label="Volume"
className="h-1 w-20 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
style={{
background: `linear-gradient(to right, rgba(255,255,255,0.85) ${
(muted ? 0 : volume) * 100
}%, rgba(255,255,255,0.25) ${(muted ? 0 : volume) * 100}%)`,
}}
/>
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
</button>
<button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
{full ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
)}
</svg>
</button>
</div>
);
}
+35 -6
View File
@@ -8,10 +8,14 @@ interface Props {
onOpenSettings: () => void; onOpenSettings: () => void;
totalVideos: number; totalVideos: number;
totalDownloaded: number; totalDownloaded: number;
onHide: () => void;
/** True when revealed by hover over the left edge rather than pinned open. */
floating?: boolean;
} }
export default function Sidebar({ export default function Sidebar({
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded, channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
onHide, floating,
}: Props) { }: Props) {
const row = const row =
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " + "flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
@@ -22,9 +26,14 @@ export default function Sidebar({
return ( return (
<aside <aside
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b className={
border-slate-300 bg-white lg:h-full lg:max-h-none lg:w-[280px] "flex flex-col overflow-hidden border-slate-300 bg-white dark:border-slate-800 " +
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900" "dark:bg-slate-900 " +
(floating
? "absolute inset-y-0 left-0 z-30 w-[280px] border-r shadow-2xl"
: "max-h-[45vh] w-full shrink-0 border-b lg:h-full lg:max-h-none lg:w-[280px] " +
"lg:border-b-0 lg:border-r")
}
> >
<header <header
className="sticky top-0 z-20 flex items-center justify-between gap-2 border-b className="sticky top-0 z-20 flex items-center justify-between gap-2 border-b
@@ -35,9 +44,29 @@ export default function Sidebar({
<span aria-hidden className="text-sky-500"></span> <span aria-hidden className="text-sky-500"></span>
FlightTube FlightTube
</span> </span>
<button onClick={onOpenSettings} className={`${BTN_CHROME} cursor-pointer`}> <div className="flex shrink-0 items-center gap-1">
Settings <button
</button> onClick={onOpenSettings}
title="Settings"
aria-label="Settings"
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
</button>
<button
onClick={onHide}
title="Hide subscriptions"
aria-label="Hide subscriptions"
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
</svg>
</button>
</div>
</header> </header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3"> <nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
+35 -8
View File
@@ -1,4 +1,4 @@
import { BTN_PRIMARY, INPUT, Segmented } from "./ui"; import { INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid"; export type ViewMode = "list" | "grid";
@@ -19,6 +19,8 @@ interface Props {
resultCount: number; resultCount: number;
view: ViewMode; view: ViewMode;
onView: (v: ViewMode) => void; onView: (v: ViewMode) => void;
sidebarHidden: boolean;
onShowSidebar: () => void;
} }
/** Neutral outline until active; active is the one filled state. */ /** Neutral outline until active; active is the one filled state. */
@@ -54,6 +56,7 @@ export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts, search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline, online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView, onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar,
}: Props) { }: Props) {
const pct = refreshProgress && refreshProgress.total > 0 const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100 ? (refreshProgress.done / refreshProgress.total) * 100
@@ -65,6 +68,21 @@ export default function TopBar({
dark:border-slate-800 dark:bg-slate-900/95" dark:border-slate-800 dark:bg-slate-900/95"
> >
<div className="flex flex-wrap items-center gap-2 px-4 py-3"> <div className="flex flex-wrap items-center gap-2 px-4 py-3">
{sidebarHidden && (
<button
onClick={onShowSidebar}
title="Show subscriptions"
aria-label="Show subscriptions"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
border border-slate-300 text-slate-500 hover:border-sky-500
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
dark:hover:border-sky-500 dark:hover:text-sky-400"
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<input <input
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
@@ -120,13 +138,22 @@ export default function TopBar({
</button> </button>
<button onClick={onRefresh} disabled={refreshing || !online} <button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos from every channel" : "Refreshing needs a connection"} title={
className={`${BTN_PRIMARY} cursor-pointer py-1.5 font-mono text-[11px] tabular-nums`}> online
{refreshing ? refreshProgress
? refreshProgress ? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
? `${refreshProgress.done}/${refreshProgress.total}` : "Fetch the latest videos from every channel"
: "Refreshing" : "Refreshing needs a connection"
: "Refresh"} }
aria-label="Refresh"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
bg-sky-500 text-white hover:bg-sky-400 disabled:cursor-not-allowed
disabled:opacity-40">
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</svg>
</button> </button>
</div> </div>
+4
View File
@@ -50,6 +50,10 @@
color: var(--color-slate-100); color: var(--color-slate-100);
} }
/* A fullscreened element is painted over a black backdrop, so it has to
carry its own background rather than inheriting the page's. */
:fullscreen { background: #000; }
/* Borders do the separating; the scrollbar should not compete. */ /* Borders do the separating; the scrollbar should not compete. */
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }