feat: uniform control height, chrome auto-hide, streaming quality
Every control in the app now shares one height (CONTROL_H), with width still growing to fit its label. The player transport is larger, Back is an icon, and the footer buttons match the prev/next size. Player chrome — transport and edge arrows — fades after 2.6s of inactivity and never hides while paused. The title-bar strip collapses in macOS window fullscreen, where the traffic lights are gone and it was just a blank white bar. Streaming quality is now selectable alongside download quality. A cap is applied by rewriting YouTube's HLS master playlist down to the best variant at or below the chosen height, keeping its audio group. That playlist is served from a small loopback HTTP server: Safari's native HLS is backed by AVFoundation, which cannot read blob: or custom-scheme URLs, so a Blob URL silently fails to play. Settings closes with an icon button and its selects match the shared control height.
This commit is contained in:
+43
-11
@@ -16,6 +16,8 @@ interface Props {
|
||||
/** Present only while streaming, so the video can be saved from here. */
|
||||
onDownload?: () => void;
|
||||
downloading?: boolean;
|
||||
/** Max height for streaming, or null to let the player adapt. */
|
||||
maxHeight: number | null;
|
||||
/** Position in the current feed, for the "3 of 180" readout. */
|
||||
index: number;
|
||||
total: number;
|
||||
@@ -92,12 +94,16 @@ const RESUME_EDGE_S = 5;
|
||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||
*/
|
||||
export default function Player({
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total,
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||
maxHeight, 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 [buffering, setBuffering] = useState(true);
|
||||
// Controls and edge arrows fade away while you are just watching.
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const hideTimer = useRef<number | undefined>(undefined);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const lastSave = useRef(0);
|
||||
@@ -110,13 +116,14 @@ export default function Player({
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
setError(null);
|
||||
resolveStream(item.id)
|
||||
resolveStream(item.id, maxHeight)
|
||||
.then((u) => !cancelled && setSrc(u))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.id, path]);
|
||||
}, [item.id, path, maxHeight]);
|
||||
|
||||
|
||||
const persist = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -176,8 +183,22 @@ export default function Player({
|
||||
const edgeBtn =
|
||||
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
||||
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
||||
"opacity-0 transition-opacity group-hover/stage:opacity-100 focus-visible:opacity-100 " +
|
||||
"hover:bg-slate-950/80 disabled:hidden";
|
||||
"transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
|
||||
(chromeVisible ? "opacity-100" : "pointer-events-none opacity-0");
|
||||
|
||||
const showChrome = useCallback(() => {
|
||||
setChromeVisible(true);
|
||||
window.clearTimeout(hideTimer.current);
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Never hide while paused — there would be no way back to play.
|
||||
if (!videoRef.current?.paused) setChromeVisible(false);
|
||||
}, 2600);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
showChrome();
|
||||
return () => window.clearTimeout(hideTimer.current);
|
||||
}, [showChrome, src]);
|
||||
|
||||
const navBtn =
|
||||
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
||||
@@ -192,8 +213,11 @@ export default function Player({
|
||||
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"
|
||||
>
|
||||
<button onClick={leave} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
← Back
|
||||
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
|
||||
className={`${navBtn} w-[30px] p-0`}>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||||
@@ -229,7 +253,9 @@ export default function Player({
|
||||
<div
|
||||
ref={stageRef}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="group/stage relative min-h-0 flex-1 bg-slate-950"
|
||||
onMouseMove={showChrome}
|
||||
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
|
||||
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
|
||||
>
|
||||
{/* Edge arrows, the way a player wants them: big targets on the left and
|
||||
right of the picture. They fade in on hover so they never sit on top
|
||||
@@ -301,7 +327,13 @@ export default function Player({
|
||||
)
|
||||
)}
|
||||
{src && !error && (
|
||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} />
|
||||
<div
|
||||
className={`transition-opacity duration-200 ${
|
||||
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -323,14 +355,14 @@ export default function Player({
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
||||
className={`${navBtn} whitespace-nowrap`}
|
||||
>
|
||||
{downloading ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
||||
className={`${navBtn} whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user