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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { LiveDownload } from "../hooks/useDownloads";
|
||||
import type { FeedItem } from "../types";
|
||||
import { humanEta } from "./format";
|
||||
import { CONTROL_H } from "./ui";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
@@ -11,7 +12,9 @@ interface Props {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer";
|
||||
const CHIP =
|
||||
`inline-flex ${CONTROL_H} shrink-0 cursor-pointer items-center justify-center rounded-lg ` +
|
||||
"px-2.5 text-[12px] font-medium";
|
||||
|
||||
export default function DownloadButton({
|
||||
item, live, online, onDownload, onCancel, onDelete,
|
||||
@@ -38,22 +41,18 @@ export default function DownloadButton({
|
||||
const known = state === "running" && pct != null;
|
||||
return (
|
||||
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
|
||||
className={`${CHIP} group w-[104px] border border-slate-300 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`}>
|
||||
<span className="hidden group-hover:block">Cancel</span>
|
||||
<span className="block group-hover:hidden">
|
||||
<span className="mb-1 block font-mono tabular-nums">
|
||||
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
|
||||
</span>
|
||||
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<span
|
||||
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${
|
||||
known ? "" : "animate-pulse"
|
||||
}`}
|
||||
style={{ width: known ? `${pct}%` : "35%" }}
|
||||
/>
|
||||
</span>
|
||||
className={`${CHIP} group relative w-[104px] overflow-hidden border border-slate-300
|
||||
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`}>
|
||||
{/* Progress fills the chip itself, so the control stays one line tall. */}
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 bg-sky-500/15 transition-[width] duration-100"
|
||||
style={{ width: known ? `${pct}%` : "35%" }}
|
||||
/>
|
||||
<span className="relative hidden group-hover:inline">Cancel</span>
|
||||
<span className="relative inline font-mono tabular-nums group-hover:hidden">
|
||||
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
+43
-11
@@ -16,6 +16,8 @@ interface Props {
|
||||
/** Present only while streaming, so the video can be saved from here. */
|
||||
onDownload?: () => void;
|
||||
downloading?: boolean;
|
||||
/** Max height for streaming, or null to let the player adapt. */
|
||||
maxHeight: number | null;
|
||||
/** Position in the current feed, for the "3 of 180" readout. */
|
||||
index: number;
|
||||
total: number;
|
||||
@@ -92,12 +94,16 @@ const RESUME_EDGE_S = 5;
|
||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||
*/
|
||||
export default function Player({
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total,
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||
maxHeight, index, total,
|
||||
}: Props) {
|
||||
const streaming = path === null;
|
||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buffering, setBuffering] = useState(true);
|
||||
// Controls and edge arrows fade away while you are just watching.
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const hideTimer = useRef<number | undefined>(undefined);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const lastSave = useRef(0);
|
||||
@@ -110,13 +116,14 @@ export default function Player({
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
setError(null);
|
||||
resolveStream(item.id)
|
||||
resolveStream(item.id, maxHeight)
|
||||
.then((u) => !cancelled && setSrc(u))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.id, path]);
|
||||
}, [item.id, path, maxHeight]);
|
||||
|
||||
|
||||
const persist = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -176,8 +183,22 @@ export default function Player({
|
||||
const edgeBtn =
|
||||
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
||||
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
||||
"opacity-0 transition-opacity group-hover/stage:opacity-100 focus-visible:opacity-100 " +
|
||||
"hover:bg-slate-950/80 disabled:hidden";
|
||||
"transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
|
||||
(chromeVisible ? "opacity-100" : "pointer-events-none opacity-0");
|
||||
|
||||
const showChrome = useCallback(() => {
|
||||
setChromeVisible(true);
|
||||
window.clearTimeout(hideTimer.current);
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Never hide while paused — there would be no way back to play.
|
||||
if (!videoRef.current?.paused) setChromeVisible(false);
|
||||
}, 2600);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
showChrome();
|
||||
return () => window.clearTimeout(hideTimer.current);
|
||||
}, [showChrome, src]);
|
||||
|
||||
const navBtn =
|
||||
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
||||
@@ -192,8 +213,11 @@ export default function Player({
|
||||
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"
|
||||
>
|
||||
<button onClick={leave} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
← Back
|
||||
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
|
||||
className={`${navBtn} w-[30px] p-0`}>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||||
@@ -229,7 +253,9 @@ export default function Player({
|
||||
<div
|
||||
ref={stageRef}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="group/stage relative min-h-0 flex-1 bg-slate-950"
|
||||
onMouseMove={showChrome}
|
||||
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
|
||||
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
|
||||
>
|
||||
{/* 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
|
||||
@@ -301,7 +327,13 @@ export default function Player({
|
||||
)
|
||||
)}
|
||||
{src && !error && (
|
||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} />
|
||||
<div
|
||||
className={`transition-opacity duration-200 ${
|
||||
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -323,14 +355,14 @@ export default function Player({
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
||||
className={`${navBtn} whitespace-nowrap`}
|
||||
>
|
||||
{downloading ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
||||
className={`${navBtn} whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ function clock(seconds: number): string {
|
||||
}
|
||||
|
||||
const btn =
|
||||
"grid size-8 shrink-0 place-items-center rounded-md text-white/90 cursor-pointer " +
|
||||
"grid size-10 shrink-0 place-items-center rounded-lg text-white/90 cursor-pointer " +
|
||||
"hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
/**
|
||||
@@ -113,17 +113,17 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
<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"
|
||||
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t
|
||||
from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10"
|
||||
>
|
||||
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
|
||||
{playing ? (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
|
||||
<svg viewBox="0 0 24 24" className="size-5" 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">
|
||||
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
|
||||
<path d="M8 5.5v13l11-6.5z" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -134,7 +134,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
className={btn}
|
||||
title={`Back ${SKIP_S}s`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg viewBox="0 0 24 24" className="size-5" 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>
|
||||
@@ -143,12 +143,12 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
className={btn}
|
||||
title={`Forward ${SKIP_S}s`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg viewBox="0 0 24 24" className="size-5" 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>
|
||||
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
@@ -162,20 +162,20 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
onActivity?.();
|
||||
}}
|
||||
aria-label="Seek"
|
||||
className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
|
||||
className="h-1.5 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>
|
||||
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{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">
|
||||
<svg viewBox="0 0 24 24" className="size-5" 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" />
|
||||
@@ -204,7 +204,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
onActivity?.();
|
||||
}}
|
||||
aria-label="Volume"
|
||||
className="h-1 w-20 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
|
||||
className="h-1.5 w-24 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
|
||||
@@ -213,14 +213,14 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
/>
|
||||
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" className="size-5" 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">
|
||||
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
{full ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
||||
) : (
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
||||
} from "../api";
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types";
|
||||
import {
|
||||
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
|
||||
} from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
import {
|
||||
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||
BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||
} from "./ui";
|
||||
|
||||
interface Props {
|
||||
@@ -16,9 +18,15 @@ interface Props {
|
||||
onAppearance: (a: Appearance) => void;
|
||||
quality: Quality;
|
||||
onQuality: (q: Quality) => void;
|
||||
streamQuality: Quality;
|
||||
onStreamQuality: (q: Quality) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const SELECT =
|
||||
`w-full ${CONTROL_H} cursor-pointer rounded-lg border border-slate-300 bg-white px-2 ` +
|
||||
"text-[12px] outline-none dark:border-slate-700 dark:bg-slate-800";
|
||||
|
||||
function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
|
||||
@@ -35,7 +43,8 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
}
|
||||
|
||||
export default function Settings({
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||
streamQuality, onStreamQuality, onError,
|
||||
}: Props) {
|
||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||
@@ -96,7 +105,17 @@ export default function Settings({
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
|
||||
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
aria-label="Close settings"
|
||||
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900
|
||||
dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
@@ -135,9 +154,7 @@ export default function Settings({
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => onQuality(e.target.value as Quality)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5
|
||||
text-[13px] outline-none cursor-pointer
|
||||
dark:border-slate-700 dark:bg-slate-800"
|
||||
className={SELECT}
|
||||
>
|
||||
{QUALITIES.map((q) => (
|
||||
<option key={q.value} value={q.value}>
|
||||
@@ -146,6 +163,25 @@ export default function Settings({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||
<span className={LABEL}>Streaming</span>
|
||||
<select
|
||||
value={streamQuality}
|
||||
onChange={(e) => onStreamQuality(e.target.value as Quality)}
|
||||
className={SELECT}
|
||||
>
|
||||
{STREAM_QUALITIES.map((q) => (
|
||||
<option key={q.value} value={q.value}>
|
||||
{q.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={`mt-2 ${HELP}`}>
|
||||
Streaming quality applies when you play something you have not downloaded.
|
||||
Best lets the player adapt to your connection; a fixed height pins it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelWithCount } from "../types";
|
||||
import { BTN_CHROME, HEADING } from "./ui";
|
||||
import { HEADING, ICON_BTN } from "./ui";
|
||||
|
||||
interface Props {
|
||||
channels: ChannelWithCount[];
|
||||
@@ -49,7 +49,7 @@ export default function Sidebar({
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
|
||||
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
@@ -60,7 +60,7 @@ export default function Sidebar({
|
||||
onClick={onHide}
|
||||
title="Hide subscriptions"
|
||||
aria-label="Hide subscriptions"
|
||||
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
|
||||
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||
>
|
||||
<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" />
|
||||
|
||||
+12
-15
@@ -1,4 +1,4 @@
|
||||
import { INPUT, Segmented } from "./ui";
|
||||
import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui";
|
||||
|
||||
export type ViewMode = "list" | "grid";
|
||||
|
||||
@@ -39,7 +39,7 @@ function Toggle({
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={
|
||||
"cursor-pointer whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-[11px] " +
|
||||
`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"
|
||||
@@ -73,10 +73,9 @@ export default function TopBar({
|
||||
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"
|
||||
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" />
|
||||
@@ -87,7 +86,7 @@ export default function TopBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search videos and channels"
|
||||
className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`}
|
||||
className={`${INPUT} min-w-72 max-w-sm flex-1`}
|
||||
/>
|
||||
|
||||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||
@@ -125,11 +124,11 @@ export default function TopBar({
|
||||
? "Offline mode is forced on — click to go back online"
|
||||
: "Simulate being offline"
|
||||
}
|
||||
className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border
|
||||
border-slate-300 px-2.5 py-1.5 text-[11px] 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"
|
||||
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"}`}
|
||||
@@ -146,9 +145,7 @@ export default function TopBar({
|
||||
: "Refreshing needs a connection"
|
||||
}
|
||||
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">
|
||||
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"
|
||||
|
||||
+22
-9
@@ -4,6 +4,17 @@
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Every control in the app is this tall. Width still grows with the label —
|
||||
* only the height is fixed, so a row of mixed buttons lines up.
|
||||
*/
|
||||
export const CONTROL_H = "h-[30px]";
|
||||
|
||||
/** Square version, for icon-only buttons. */
|
||||
export const ICON_BTN =
|
||||
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
|
||||
"disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export const HEADING =
|
||||
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
|
||||
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
|
||||
@@ -14,27 +25,29 @@ export const PANEL =
|
||||
export const SECTION =
|
||||
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
|
||||
export const INPUT =
|
||||
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " +
|
||||
`w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
|
||||
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
|
||||
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
|
||||
|
||||
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed";
|
||||
const BTN_BASE =
|
||||
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
|
||||
"disabled:cursor-not-allowed";
|
||||
|
||||
export const BTN =
|
||||
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` +
|
||||
`${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
|
||||
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
|
||||
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
|
||||
|
||||
export const BTN_PRIMARY =
|
||||
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` +
|
||||
`${BTN_BASE} bg-sky-500 px-3 font-semibold text-white ` +
|
||||
"hover:bg-sky-400 disabled:opacity-40";
|
||||
|
||||
export const BTN_DANGER =
|
||||
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`;
|
||||
`${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
|
||||
|
||||
/** Header actions: quieter than a secondary button, still a real target. */
|
||||
export const BTN_CHROME =
|
||||
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " +
|
||||
`inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
|
||||
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
|
||||
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
|
||||
|
||||
@@ -85,7 +98,7 @@ export function Segmented<T extends string>({
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700"
|
||||
className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
@@ -94,7 +107,7 @@ export function Segmented<T extends string>({
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={
|
||||
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " +
|
||||
"h-full rounded-md px-2.5 text-[12px] font-medium transition-colors cursor-pointer " +
|
||||
(active
|
||||
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
|
||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
|
||||
@@ -172,7 +185,7 @@ export function Dialog({
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`}
|
||||
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
|
||||
>
|
||||
{confirmLabel ?? "Continue"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user