feat: tile view, in-app streaming, and self-painted title bar

Clicking an undownloaded video now plays it in the app instead of
handing off to the browser. YouTube's iframe embed cannot be used —
it rejects a tauri:// origin with Error 153 — so yt-dlp resolves
YouTube's HLS master playlist instead, whose H.264+AAC variants
AVFoundation streams natively in WKWebView, adaptive up to 1080p.

Adds an optional Tiles view alongside the list, remembered between
launches.

The title bar is now transparent with the title hidden, and the app
paints that strip itself in the page background so it matches instead
of showing macOS chrome beside the traffic lights.
This commit is contained in:
vincent
2026-08-29 03:24:24 +02:00
parent ccb25fad33
commit f47bc3dab1
9 changed files with 297 additions and 34 deletions
+80
View File
@@ -0,0 +1,80 @@
import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton";
import { compactViews, relativeTime } from "./format";
interface Props {
item: FeedItem;
live?: LiveDownload;
online: boolean;
onOpen: () => void;
onDownload: () => void;
onCancel: () => void;
onDelete: () => void;
}
/** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */
export default function VideoTile({
item, live, online, onOpen, onDownload, onCancel, onDelete,
}: Props) {
const downloaded = (live?.state ?? item.state) === "done";
const src = thumbSrc(item, online);
return (
<li className="group flex flex-col">
<button
onClick={onOpen}
className="relative aspect-video w-full cursor-pointer overflow-hidden rounded-lg
border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-800"
>
{src ? (
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
) : (
<span className="grid size-full place-items-center px-2 text-center text-[10px] text-slate-400">
No thumbnail
</span>
)}
{item.is_short && (
<span
className="absolute left-1.5 top-1.5 rounded bg-slate-950/75 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Short
</span>
)}
{downloaded && (
<span
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Offline
</span>
)}
</button>
<div className="mt-2 flex min-w-0 flex-1 flex-col">
<button onClick={onOpen} className="cursor-pointer text-left">
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
</button>
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</div>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
<div className="mt-1 line-clamp-2 text-[11px] text-red-600 dark:text-red-400">
{live?.error ?? item.error}
</div>
)}
<div className="mt-2 flex">
<DownloadButton item={item} live={live} online={online}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</div>
</div>
</li>
);
}