From 211823f2655db6f0e693cfb822625263b0cc1536 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 11:03:29 +0200 Subject: [PATCH] feat: custom transport bar, live refresh, collapsible sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 99 +++++++++++-- src/components/Player.tsx | 84 +++++++---- src/components/PlayerControls.tsx | 233 ++++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 41 +++++- src/components/TopBar.tsx | 43 +++++- src/index.css | 4 + 6 files changed, 448 insertions(+), 56 deletions(-) create mode 100644 src/components/PlayerControls.tsx diff --git a/src/App.tsx b/src/App.tsx index ae9f762..7a178d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(() => { 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,23 +202,52 @@ export default function App() { return (
- {/* 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. */}
-
- setShowSettings(true)} - totalVideos={totals.videos} - totalDownloaded={totals.downloaded} - /> +
+ {/* With the sidebar hidden, a thin strip along the left edge brings it + back on hover. */} + {sidebarHidden && ( +
setSidebarPeek(true)} + className="absolute inset-y-0 left-0 z-20 w-3" + aria-hidden + /> + )} + {sidebarHidden && sidebarPeek && ( +
setSidebarPeek(false)} + className="absolute inset-y-0 left-0 z-30 w-[280px]" + > + { setChannelId(id); setSidebarPeek(false); }} + onOpenSettings={() => setShowSettings(true)} + totalVideos={totals.videos} + totalDownloaded={totals.downloaded} + onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }} + /> +
+ )} + + {!sidebarHidden && ( + setShowSettings(true)} + totalVideos={totals.videos} + totalDownloaded={totals.downloaded} + onHide={() => setSidebarHidden(true)} + /> + )}
{ 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); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 818494a..24f2e7d 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -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(path ? fileUrl(path) : null); const [error, setError] = useState(null); - const [buffering, setBuffering] = useState(false); + const [buffering, setBuffering] = useState(true); const videoRef = useRef(null); const stageRef = useRef(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. */} -
+
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({ › - {/* Two different waits look the same to you: resolving the stream, and - the player buffering it. Both get the spinner. */} - {src && buffering && ( -
+ {/* 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 && ( +
)} @@ -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 ? ( + error && ( +

{error}

- ) : ( -
- - Finding a stream… -
- )} -
+
+ ) + )} + {src && !error && ( + )}
@@ -297,12 +318,23 @@ export default function Player({ .join(" · ")}
- +
+ {onDownload && ( + + )} + +
{/* Collapsed by default — the description is rarely what you came for. */} diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx new file mode 100644 index 0000000..9ee2d2d --- /dev/null +++ b/src/components/PlayerControls.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useState } from "react"; + +interface Props { + videoRef: React.RefObject; + /** Element to fullscreen — the stage, so letterboxing travels with it. */ + stageRef: React.RefObject; + /** 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 ( +
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" + > + + + + + + {clock(time)} + + { + 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}%)`, + }} + /> + + {clock(duration)} + + + { + 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}%)`, + }} + /> + + + + +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1ea308c..8d3a0a4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 (