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:
+170
-25
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user