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>
+25 -2
View File
@@ -3,7 +3,7 @@ import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import type { ImportPreview, Prereqs } from "../types";
import type { ImportPreview, Prereqs, Quality } from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
@@ -14,6 +14,8 @@ interface Props {
onImported: (count: number) => void;
appearance: Appearance;
onAppearance: (a: Appearance) => void;
quality: Quality;
onQuality: (q: Quality) => void;
onError: (message: string) => void;
}
@@ -33,7 +35,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
}
export default function Settings({
onClose, onImported, appearance, onAppearance, onError,
onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
@@ -121,6 +123,27 @@ export default function Settings({
</button>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Download quality</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
<b>Best</b> takes the highest resolution available, up to 4K above 1080p
that means VP9 or AV1, which your Mac decodes but older ones may not, and
the files are several times larger. <b>Compatible</b> caps at 1080p H.264,
which plays anywhere. Audio is AAC either way.
</p>
<div className="mt-2 flex items-center justify-between gap-3">
<span className={LABEL}>Quality</span>
<Segmented
value={quality}
onChange={onQuality}
options={[
{ value: "best", label: "Best (4K)" },
{ value: "compatible", label: "Compatible" },
]}
/>
</div>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3">
+1 -1
View File
@@ -69,7 +69,7 @@ export default function TopBar({
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels"
className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`}
className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`}
/>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
+3 -1
View File
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton";
import WatchBar from "./WatchBar";
import { compactViews, relativeTime } from "./format";
interface Props {
@@ -48,12 +49,13 @@ export default function VideoRow({
)}
{downloaded && (
<span
className="absolute bottom-1 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
className="absolute bottom-1.5 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Offline
</span>
)}
<WatchBar item={item} />
</button>
<div className="min-w-0 flex-1">
+27 -12
View File
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import DownloadButton from "./DownloadButton";
import WatchBar from "./WatchBar";
import { compactViews, relativeTime } from "./format";
interface Props {
@@ -45,35 +46,49 @@ export default function VideoTile({
)}
{downloaded && (
<span
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
className="absolute bottom-2 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>
)}
<WatchBar item={item} />
</button>
<div className="mt-2 flex min-w-0 flex-1 flex-col">
{/* Every text block is a fixed height and every line is clamped, so tiles
stay on a shared baseline no matter how long a title runs. */}
<div className="mt-2 flex min-w-0 flex-col">
<button onClick={onOpen} className="cursor-pointer text-left">
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
<h3
className="line-clamp-2 h-[2.25rem] text-[13px] font-medium leading-snug"
title={item.title}
>
{item.title}
</h3>
</button>
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
<div
className="mt-1 h-4 truncate text-[12px] leading-4 text-slate-500 dark:text-slate-400"
title={item.channel_title}
>
{item.channel_title}
</div>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
<div className="mt-0.5 h-4 truncate text-[11px] leading-4 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">
<div className="mt-2 flex h-7 items-start">
<DownloadButton item={item} live={live} online={online}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
<div
className="mt-1 line-clamp-1 text-[11px] text-red-600 dark:text-red-400"
title={live?.error ?? item.error ?? undefined}
>
{live?.error ?? item.error}
</div>
)}
</div>
</li>
);
+30
View File
@@ -0,0 +1,30 @@
import type { FeedItem } from "../types";
/** Fraction of the video watched, or null if it was never opened. */
export function watchedFraction(item: FeedItem): number | null {
const { position, duration } = item;
if (position == null || duration == null || duration <= 0) return null;
return Math.min(1, Math.max(0, position / duration));
}
/**
* The red sliver across the bottom of a thumbnail, same idea as YouTube's.
* Red rather than the sky accent on purpose: the accent means "the action to
* take" or "currently selected", and this is neither.
*/
export default function WatchBar({ item }: { item: FeedItem }) {
const f = watchedFraction(item);
if (f == null || f < 0.01) return null;
const nearlyDone = f > 0.97;
return (
<span
className="absolute inset-x-0 bottom-0 h-[3px] bg-slate-950/45"
title={nearlyDone ? "Watched" : `${Math.round(f * 100)}% watched`}
>
<span
className="block h-full bg-red-600"
style={{ width: `${Math.max(2, f * 100)}%` }}
/>
</span>
);
}
+10
View File
@@ -43,6 +43,16 @@ export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/** The only spinning thing in the app; used where a wait has no known length. */
export function Spinner({ className = "size-4" }: { className?: string }) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function SectionHeading({
step,
children,