List and Tiles are now icons rather than words, centred in their buttons — Segmented takes a ReactNode label and an optional title. The red "N failing" count in the sidebar is a button: click it to see only the channels that failed to refresh, click again for all of them. It clears itself once a refresh fixes everything, so the filter can never strand you on an empty list. The player states its quality as a badge instead of loose grey text, and a downloaded video now says so too — "Downloaded · 1440p" — read from the file itself. Quality is the short side, so a portrait Short reads 1080p rather than 1920p, and it resets between videos so one never inherits the previous one's number.
203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui";
|
|
|
|
export type ViewMode = "list" | "grid";
|
|
|
|
interface Props {
|
|
search: string;
|
|
onSearch: (v: string) => void;
|
|
downloadedOnly: boolean;
|
|
onDownloadedOnly: (v: boolean) => void;
|
|
online: boolean;
|
|
reachable: boolean;
|
|
forcedOffline: boolean;
|
|
onToggleForcedOffline: () => void;
|
|
onRefresh: () => void;
|
|
refreshing: boolean;
|
|
refreshProgress: { done: number; total: number } | null;
|
|
resultCount: number;
|
|
view: ViewMode;
|
|
onView: (v: ViewMode) => void;
|
|
sidebarHidden: boolean;
|
|
onShowSidebar: () => void;
|
|
/** Present only when there is something to delete. */
|
|
onDeleteAll?: () => void;
|
|
/** Matches the sidebar's inset so the two headers share a baseline. */
|
|
titleBarInset: boolean;
|
|
}
|
|
|
|
/** Neutral outline until active; active is the one filled state. */
|
|
function Toggle({
|
|
active, onClick, children, title, disabled,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
title?: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={title}
|
|
disabled={disabled}
|
|
className={
|
|
`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap rounded-lg border px-2.5 text-[12px] ` +
|
|
"font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
|
|
(active
|
|
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
|
|
: "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")
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function TopBar({
|
|
search, onSearch, downloadedOnly, onDownloadedOnly,
|
|
online, reachable, forcedOffline, onToggleForcedOffline,
|
|
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
|
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
|
}: Props) {
|
|
const pct = refreshProgress && refreshProgress.total > 0
|
|
? (refreshProgress.done / refreshProgress.total) * 100
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur
|
|
dark:border-slate-800 dark:bg-slate-900/95"
|
|
>
|
|
{titleBarInset && <div data-tauri-drag-region className="h-9" />}
|
|
|
|
<div className="flex flex-nowrap items-center gap-2 px-4 py-3">
|
|
{sidebarHidden && (
|
|
<button
|
|
onClick={onShowSidebar}
|
|
title="Show subscriptions"
|
|
aria-label="Show subscriptions"
|
|
className={`${ICON_BTN} 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
|
|
value={search}
|
|
onChange={(e) => onSearch(e.target.value)}
|
|
placeholder="Search videos and channels"
|
|
className={`${INPUT} min-w-[17rem] max-w-sm flex-1`}
|
|
/>
|
|
|
|
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
|
{resultCount}
|
|
</span>
|
|
|
|
<div className="min-w-2 flex-1" />
|
|
|
|
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
|
|
disabled={!online}
|
|
title={online ? "Show only what is on this Mac" : "Offline: showing local videos only"}>
|
|
Local
|
|
</Toggle>
|
|
|
|
{downloadedOnly && onDeleteAll && (
|
|
<button
|
|
onClick={onDeleteAll}
|
|
title="Delete every download"
|
|
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap
|
|
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium
|
|
text-slate-500 hover:border-red-500 hover:text-red-600
|
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
|
|
dark:hover:text-red-400`}
|
|
>
|
|
Delete all
|
|
</button>
|
|
)}
|
|
|
|
<Segmented
|
|
value={view}
|
|
onChange={onView}
|
|
options={[
|
|
{
|
|
value: "list",
|
|
title: "List",
|
|
label: (
|
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path strokeLinecap="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
value: "grid",
|
|
title: "Tiles",
|
|
label: (
|
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="4" y="4" width="7" height="7" rx="1.5" />
|
|
<rect x="13" y="4" width="7" height="7" rx="1.5" />
|
|
<rect x="4" y="13" width="7" height="7" rx="1.5" />
|
|
<rect x="13" y="13" width="7" height="7" rx="1.5" />
|
|
</svg>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<button
|
|
onClick={onToggleForcedOffline}
|
|
title={
|
|
!reachable
|
|
? "No connection detected"
|
|
: forcedOffline
|
|
? "Offline mode is forced on — click to go back online"
|
|
: "Simulate being offline"
|
|
}
|
|
className={`inline-flex ${CONTROL_H} cursor-pointer items-center gap-1.5 whitespace-nowrap
|
|
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium 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`}
|
|
>
|
|
<span
|
|
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
|
|
/>
|
|
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
|
|
</button>
|
|
|
|
<button onClick={onRefresh} disabled={refreshing || !online}
|
|
title={
|
|
online
|
|
? refreshProgress
|
|
? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
|
|
: "Fetch the latest videos from every channel"
|
|
: "Refreshing needs a connection"
|
|
}
|
|
aria-label="Refresh"
|
|
className={`${ICON_BTN} bg-sky-500 text-white hover:bg-sky-400`}>
|
|
<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>
|
|
</div>
|
|
|
|
{refreshing && (
|
|
<div className="h-0.5 overflow-hidden bg-slate-200 dark:bg-slate-700">
|
|
<div
|
|
className={`h-full bg-sky-500 transition-[width] duration-100 ${
|
|
pct == null ? "w-1/3 animate-pulse" : ""
|
|
}`}
|
|
style={pct == null ? undefined : { width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|