From dc1efccf87a70e3454c3afb3879e6efaefaaec17 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 15:47:32 +0200 Subject: [PATCH] feat: icon view toggle, failing filter, quality badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Player.tsx | 37 ++++++++++++++++++++++++++----------- src/components/Sidebar.tsx | 32 +++++++++++++++++++++++++++----- src/components/TopBar.tsx | 23 +++++++++++++++++++++-- src/components/ui.tsx | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 89ef52c..68bcfc1 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -3,7 +3,7 @@ import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } fro import type { FeedItem } from "../types"; import { compactViews, relativeTime } from "./format"; import PlayerControls from "./PlayerControls"; -import { BTN, Spinner } from "./ui"; +import { Badge, BTN, Spinner } from "./ui"; interface Props { item: FeedItem; @@ -131,10 +131,17 @@ export default function Player({ const [chromeVisible, setChromeVisible] = useState(true); const hideTimer = useRef(undefined); const videoRef = useRef(null); + // Quality is the short side, so a portrait Short reads 1080p, not 1920p. + const measure = useCallback(() => { + const v = videoRef.current; + if (!v?.videoHeight) return; + setHeight(Math.min(v.videoWidth, v.videoHeight)); + }, []); const stageRef = useRef(null); const lastSave = useRef(0); useEffect(() => { + setHeight(0); if (path) { setSrc(fileUrl(path)); return; @@ -266,14 +273,22 @@ export default function Player({ {item.channel_title} - {streaming && ( - - {error - ? "Unavailable" - : src && !buffering - ? `Streaming${height ? ` · ${height}p` : ""}` - : "Loading…"} - + {streaming ? ( + error ? ( + Unavailable + ) : src && !buffering ? ( + + Streaming{height ? ` · ${height}p` : ""} + + ) : ( + Loading… + ) + ) : ( + height > 0 && ( + + Downloaded · {height}p + + ) )} @@ -338,8 +353,8 @@ export default function Player({ onCanPlay={() => setBuffering(false)} onPlaying={() => setBuffering(false)} onSeeked={() => setBuffering(false)} - onResize={() => setHeight(videoRef.current?.videoHeight ?? 0)} - onLoadedData={() => setHeight(videoRef.current?.videoHeight ?? 0)} + onResize={measure} + onLoadedData={measure} className="absolute inset-0 size-full object-contain" > {sidecars.map(([lang, file]) => ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c31714b..58cba92 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + import type { ChannelWithCount } from "../types"; import { HEADING, ICON_BTN } from "./ui"; @@ -19,8 +21,17 @@ export default function Sidebar({ channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded, onHide, floating, titleBarInset, }: Props) { + const [onlyFailing, setOnlyFailing] = useState(false); const failing = channels.filter((c) => c.last_error).length; + // A filter with nothing left to show is a dead end — drop it on the next + // refresh that fixes everything. + useEffect(() => { + if (failing === 0) setOnlyFailing(false); + }, [failing]); + + const shown = onlyFailing ? channels.filter((c) => c.last_error) : channels; + const row = "flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " + "text-[13px] transition-colors"; @@ -93,18 +104,29 @@ export default function Sidebar({

Channels {failing > 0 && ( - setOnlyFailing((v) => !v)} + title={ + onlyFailing + ? "Show every channel again" + : `Show only the ${failing} channel${failing === 1 ? "" : "s"} that failed to refresh` + } + className={ + "cursor-pointer rounded-full px-1.5 py-0.5 font-mono text-[10px] normal-case " + + "tracking-normal transition-colors " + + (onlyFailing + ? "bg-red-500 text-white" + : "text-red-500 hover:bg-red-500/10") + } > {failing} failing - + )}

)}
    - {channels.map((c) => ( + {shown.map((c) => (