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:
+77
-13
@@ -1,22 +1,52 @@
|
||||
import { fileUrl } from "../api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream } from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
import { BTN, BTN_CHROME } from "./ui";
|
||||
import { BTN, BTN_CHROME, BTN_QUIET } from "./ui";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
path: string;
|
||||
/** Local file when downloaded; null means resolve and stream it instead. */
|
||||
path: string | null;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the local file through Tauri's asset protocol. Every download is
|
||||
* Two sources, one player element.
|
||||
*
|
||||
* Downloaded: the local file through Tauri's asset protocol. Every download is
|
||||
* H.264/AAC in MP4 precisely so WKWebView can decode it natively.
|
||||
*
|
||||
* Not downloaded: YouTube's HLS master playlist, resolved by yt-dlp. Its
|
||||
* variants are H.264 + AAC up to 1080p, which AVFoundation streams natively —
|
||||
* so watching still happens here rather than in a browser. The iframe embed
|
||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||
*/
|
||||
export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
const streaming = path === null;
|
||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
setSrc(fileUrl(path));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
setError(null);
|
||||
resolveStream(item.id)
|
||||
.then((u) => !cancelled && setSrc(u))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.id, path]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<header
|
||||
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
@@ -27,26 +57,60 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete download
|
||||
</button>
|
||||
{streaming ? (
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{src ? "Streaming" : error ? "Unavailable" : "Resolving…"}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete download
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||
<div className="relative min-h-0 flex-1 bg-slate-950">
|
||||
<video key={path} src={fileUrl(path)} controls autoPlay
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
{src ? (
|
||||
<video key={src} src={src} controls autoPlay
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
||||
{error ? (
|
||||
<div className="max-w-sm">
|
||||
<p className="text-[13px] text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} mt-2 cursor-pointer`}
|
||||
>
|
||||
Open on YouTube instead
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-slate-400">Finding a stream…</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
{streaming && (
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user