feat: 4K downloads, watch progress, player controls, new icon

Window chrome: titleBarStyle Overlay (Transparent left the native bar in
place, white in dark mode, with a dead strip beneath it). The app now
paints that strip itself, so it matches the page background.

Player: prev/next through the feed, a full-screen button, a spinner
while a stream resolves, Open on YouTube on downloaded videos too, and
the description collapsed behind a disclosure.

Downloads default to Best, which reaches real 4K — above 1080p YouTube
serves VP9/AV1, verified to play natively in WKWebView here. Audio stays
pinned to AAC because Opus in MP4 would be silent. A Compatible setting
keeps the old 1080p H.264 behaviour. Files are now named
'<ISO date> - <title> [<id>].mp4'.

Watch progress is recorded and drawn under thumbnails like YouTube's,
and reopening a video resumes where it left off.

Tiles clamp every text line to a fixed height so they share a baseline,
and the search field no longer clips its placeholder.

Fixes a Picture-in-Picture leak: WebKit kept a detached video playing
after the player closed, so a second video could play over the first
with no way to stop it. Every exit path now tears the element down.
This commit is contained in:
vincent
2026-08-29 04:03:45 +02:00
parent d0bad64d7c
commit 5b09acd28d
69 changed files with 582 additions and 89 deletions
+170 -25
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { fileUrl, openExternal, resolveStream } from "../api";
import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME, BTN_QUIET } from "./ui";
import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -10,8 +10,61 @@ interface Props {
path: string | null;
onClose: () => void;
onDelete: () => void;
onPrev?: () => void;
onNext?: () => void;
/** Position in the current feed, for the "3 of 180" readout. */
index: number;
total: number;
}
/**
* Releases a <video> completely.
*
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture
* session (and its audio) running after the element leaves the DOM, so closing
* the player or stepping to the next video would leave the previous one playing
* with no way to stop it. Every exit path goes through here.
*/
function teardown(v: HTMLVideoElement | null) {
if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation.
try {
const webkit = v as HTMLVideoElement & {
webkitPresentationMode?: string;
webkitSetPresentationMode?: (mode: string) => void;
};
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
webkit.webkitSetPresentationMode?.("inline");
}
} catch {
/* not supported here */
}
try {
if (document.pictureInPictureElement) void document.exitPictureInPicture();
} catch {
/* not supported here */
}
try {
if (document.fullscreenElement) void document.exitFullscreen();
} catch {
/* not supported here */
}
try {
v.pause();
// Dropping the source is what actually frees the decoder and the audio.
v.removeAttribute("src");
v.load();
} catch {
/* already gone */
}
}
/** Save at most this often while playing; also saved on close. */
const SAVE_EVERY_MS = 5000;
/** Ignore a saved position this close to either end — nothing useful to resume. */
const RESUME_EDGE_S = 5;
/**
* Two sources, one player element.
*
@@ -23,10 +76,15 @@ interface Props {
* 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) {
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, index, total,
}: Props) {
const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0);
useEffect(() => {
if (path) {
@@ -44,6 +102,52 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
};
}, [item.id, path]);
const persist = useCallback(() => {
const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
savePlayback(item.id, v.currentTime, v.duration).catch(() => {
/* a lost position is not worth interrupting playback for */
});
}, [item.id]);
// Save on the way out, including when switching to another video. Declared
// before the teardown effect so React runs this cleanup first — the position
// has to be read off the element before its source is dropped.
useEffect(() => () => persist(), [persist]);
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, [src]);
const onTimeUpdate = () => {
const now = Date.now();
if (now - lastSave.current < SAVE_EVERY_MS) return;
lastSave.current = now;
persist();
};
// Pick up where this video was left off, unless that was right at either end.
const onLoadedMetadata = () => {
const v = videoRef.current;
const at = item.position ?? 0;
if (!v || !Number.isFinite(v.duration)) return;
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
};
const goFullscreen = () => {
// The stage rather than the <video>, so our letterboxing travels with it.
stageRef.current?.requestFullscreen?.().catch(() => {
videoRef.current?.requestFullscreen?.().catch(() => {});
});
};
const navBtn =
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 disabled:cursor-not-allowed " +
"disabled:hover:border-slate-300 disabled:hover:text-inherit " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
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" />
@@ -54,12 +158,28 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
Back
</button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
Prev
</button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
Next
</button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{index + 1}/{total}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</span>
<button onClick={goFullscreen} disabled={!src} title="Full screen" className={navBtn}>
Full screen
</button>
{streaming ? (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{src ? "Streaming" : error ? "Unavailable" : "Resolving"}
{src ? "Streaming" : error ? "Unavailable" : "Loading"}
</span>
) : (
<button
@@ -73,10 +193,19 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
{/* 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">
<div ref={stageRef} className="relative min-h-0 flex-1 bg-slate-950">
{src ? (
<video key={src} src={src} controls autoPlay
className="absolute inset-0 size-full object-contain" />
<video
ref={videoRef}
key={src}
src={src}
controls
autoPlay
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onPause={persist}
className="absolute inset-0 size-full object-contain"
/>
) : (
<div className="absolute inset-0 grid place-items-center px-6 text-center">
{error ? (
@@ -90,34 +219,50 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
</button>
</div>
) : (
<p className="text-[12px] text-slate-400">Finding a stream</p>
<div className="flex items-center gap-2 text-slate-400">
<Spinner />
<span className="text-[12px]">Finding a stream</span>
</div>
)}
</div>
)}
</div>
<footer
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900"
>
<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 className="min-w-0">
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<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>
{/* Collapsed by default — the description is rarely what you came for. */}
{item.description && (
<p className="mt-3 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description}
</p>
<details className="group mt-2">
<summary
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
>
<span className="inline-block transition-transform group-open:rotate-90"></span>{" "}
Description
</summary>
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description}
</p>
</details>
)}
</footer>
</div>