feat: icon view toggle, failing filter, quality badges

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.
This commit is contained in:
vincent
2026-08-29 15:47:32 +02:00
parent 48acaa359f
commit dc1efccf87
4 changed files with 105 additions and 20 deletions
+26 -11
View File
@@ -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<number | undefined>(undefined);
const videoRef = useRef<HTMLVideoElement>(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<HTMLDivElement>(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}
</span>
{streaming && (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{error
? "Unavailable"
: src && !buffering
? `Streaming${height ? ` · ${height}p` : ""}`
: "Loading…"}
</span>
{streaming ? (
error ? (
<Badge tone="danger">Unavailable</Badge>
) : src && !buffering ? (
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}>
Streaming{height ? ` · ${height}p` : ""}
</Badge>
) : (
<Badge>Loading</Badge>
)
) : (
height > 0 && (
<Badge title={`This copy on your Mac is ${height}p`}>
Downloaded · {height}p
</Badge>
)
)}
</header>
@@ -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]) => (