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) => (