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:
+75
-8
@@ -16,12 +16,24 @@ import { useFeed } from "./hooks/useFeed";
|
||||
import { 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(false);
|
||||
const [hideShorts, setHideShorts] = useState(false);
|
||||
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";
|
||||
@@ -62,10 +74,13 @@ export default function App() {
|
||||
try {
|
||||
localStorage.setItem("flighttube.view", view);
|
||||
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 {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, [view, quality]);
|
||||
}, [view, quality, 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.
|
||||
@@ -165,6 +180,16 @@ export default function App() {
|
||||
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)
|
||||
@@ -177,15 +202,42 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
{/* 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. */}
|
||||
<div
|
||||
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">
|
||||
{/* 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}
|
||||
@@ -193,7 +245,9 @@ export default function App() {
|
||||
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
|
||||
@@ -205,6 +259,8 @@ export default function App() {
|
||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||
resultCount={items.length}
|
||||
view={view} onView={setView}
|
||||
sidebarHidden={sidebarHidden}
|
||||
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
||||
/>
|
||||
|
||||
{!online && (
|
||||
@@ -276,6 +332,17 @@ export default function App() {
|
||||
? () => 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);
|
||||
|
||||
+50
-18
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
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 {
|
||||
item: FeedItem;
|
||||
@@ -12,6 +13,9 @@ interface Props {
|
||||
onDelete: () => void;
|
||||
onPrev?: () => 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. */
|
||||
index: number;
|
||||
total: number;
|
||||
@@ -88,12 +92,12 @@ 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, index, total,
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, 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(false);
|
||||
const [buffering, setBuffering] = useState(true);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const lastSave = useRef(0);
|
||||
@@ -133,6 +137,12 @@ export default function Player({
|
||||
}, [src]);
|
||||
|
||||
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();
|
||||
if (now - lastSave.current < SAVE_EVERY_MS) return;
|
||||
lastSave.current = now;
|
||||
@@ -152,7 +162,9 @@ export default function Player({
|
||||
// Escape backs out, as it does everywhere else in the app.
|
||||
useEffect(() => {
|
||||
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.
|
||||
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
|
||||
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
|
||||
@@ -214,7 +226,11 @@ export default function Player({
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
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
|
||||
right of the picture. They fade in on hover so they never sit on top
|
||||
of the video while you are watching it. */}
|
||||
@@ -237,10 +253,10 @@ export default function Player({
|
||||
›
|
||||
</button>
|
||||
|
||||
{/* Two different waits look the same to you: resolving the stream, and
|
||||
the player buffering it. Both get the spinner. */}
|
||||
{src && buffering && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
{/* Resolving the stream and buffering it are the same wait as far as
|
||||
you are concerned, so they get the same spinner in the same place. */}
|
||||
{(!src || buffering) && !error && (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
|
||||
<Spinner className="size-8 text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
@@ -250,8 +266,14 @@ export default function Player({
|
||||
ref={videoRef}
|
||||
key={src}
|
||||
src={src}
|
||||
controls
|
||||
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}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onPause={persist}
|
||||
@@ -260,27 +282,26 @@ export default function Player({
|
||||
onStalled={() => setBuffering(true)}
|
||||
onCanPlay={() => setBuffering(false)}
|
||||
onPlaying={() => setBuffering(false)}
|
||||
onSeeked={() => setBuffering(false)}
|
||||
className="absolute inset-0 size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
error && (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
||||
{error ? (
|
||||
<div className="max-w-sm">
|
||||
<p className="text-[13px] text-red-400">{error}</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Spinner />
|
||||
<span className="text-[12px]">Finding a stream…</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{src && !error && (
|
||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -297,13 +318,24 @@ export default function Player({
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
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_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
|
||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsed by default — the description is rarely what you came for. */}
|
||||
{item.description && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ interface Props {
|
||||
onOpenSettings: () => void;
|
||||
totalVideos: number;
|
||||
totalDownloaded: number;
|
||||
onHide: () => void;
|
||||
/** True when revealed by hover over the left edge rather than pinned open. */
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
||||
onHide, floating,
|
||||
}: Props) {
|
||||
const row =
|
||||
"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 (
|
||||
<aside
|
||||
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
|
||||
border-slate-300 bg-white lg:h-full lg:max-h-none lg:w-[280px]
|
||||
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
|
||||
className={
|
||||
"flex flex-col overflow-hidden border-slate-300 bg-white dark:border-slate-800 " +
|
||||
"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
|
||||
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>
|
||||
FlightTube
|
||||
</span>
|
||||
<button onClick={onOpenSettings} className={`${BTN_CHROME} cursor-pointer`}>
|
||||
Settings
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<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>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BTN_PRIMARY, INPUT, Segmented } from "./ui";
|
||||
import { INPUT, Segmented } from "./ui";
|
||||
|
||||
export type ViewMode = "list" | "grid";
|
||||
|
||||
@@ -19,6 +19,8 @@ interface Props {
|
||||
resultCount: number;
|
||||
view: ViewMode;
|
||||
onView: (v: ViewMode) => void;
|
||||
sidebarHidden: boolean;
|
||||
onShowSidebar: () => void;
|
||||
}
|
||||
|
||||
/** Neutral outline until active; active is the one filled state. */
|
||||
@@ -54,6 +56,7 @@ export default function TopBar({
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||
sidebarHidden, onShowSidebar,
|
||||
}: Props) {
|
||||
const pct = refreshProgress && refreshProgress.total > 0
|
||||
? (refreshProgress.done / refreshProgress.total) * 100
|
||||
@@ -65,6 +68,21 @@ export default function TopBar({
|
||||
dark:border-slate-800 dark:bg-slate-900/95"
|
||||
>
|
||||
<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
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
@@ -120,13 +138,22 @@ export default function TopBar({
|
||||
</button>
|
||||
|
||||
<button onClick={onRefresh} disabled={refreshing || !online}
|
||||
title={online ? "Fetch the latest videos from every channel" : "Refreshing needs a connection"}
|
||||
className={`${BTN_PRIMARY} cursor-pointer py-1.5 font-mono text-[11px] tabular-nums`}>
|
||||
{refreshing
|
||||
title={
|
||||
online
|
||||
? refreshProgress
|
||||
? `${refreshProgress.done}/${refreshProgress.total}`
|
||||
: "Refreshing"
|
||||
: "Refresh"}
|
||||
? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
|
||||
: "Fetch the latest videos from every channel"
|
||||
: "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">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
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. */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
Reference in New Issue
Block a user