From 2f0a7083648e1d585c3abf708cc54b56f7314c2e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 31 Mar 2026 23:29:17 -0500 Subject: [PATCH] Fix YouTube player volume and custom URL handling - Apply volume changes to the embedded iframe player - Surface validation errors in the custom URL editor - Improve playlist controls and player affordances --- apps/web/src/components/YouTubePlayer.tsx | 194 +++++++++++++++++----- apps/web/src/youtubePlayerStore.ts | 1 - 2 files changed, 153 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/YouTubePlayer.tsx b/apps/web/src/components/YouTubePlayer.tsx index 62af0a738..58beb8cc8 100644 --- a/apps/web/src/components/YouTubePlayer.tsx +++ b/apps/web/src/components/YouTubePlayer.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, ChevronUpIcon, + ListMusicIcon, MaximizeIcon, MinimizeIcon, Music2Icon, @@ -12,7 +13,7 @@ import { VolumeXIcon, XIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildYouTubeEmbedUrl, DEFAULT_PLAYLISTS, @@ -22,6 +23,16 @@ import { import type { CustomSlot } from "../youtubePlayerStore"; import { cn } from "~/lib/utils"; +// --------------------------------------------------------------------------- +// YouTube IFrame postMessage helpers +// --------------------------------------------------------------------------- + +/** Send a command to the YouTube IFrame Player API via postMessage. */ +function ytCommand(iframe: HTMLIFrameElement | null, func: string, args: unknown[] = []) { + if (!iframe?.contentWindow) return; + iframe.contentWindow.postMessage(JSON.stringify({ event: "command", func, args }), "*"); +} + // --------------------------------------------------------------------------- // Compact mini-bar shown at the bottom of the sidebar // --------------------------------------------------------------------------- @@ -56,12 +67,24 @@ export function YouTubeToggleButton() { } // --------------------------------------------------------------------------- -// Volume slider +// Volume slider — controls the actual iframe player volume // --------------------------------------------------------------------------- -function VolumeControl() { +function VolumeControl({ iframeRef }: { iframeRef: React.RefObject }) { const { volume, setVolume } = useYouTubePlayerStore(); const [premuteVolume, setPremuteVolume] = useState(80); + // Sync volume to the YouTube iframe whenever it changes + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + if (volume === 0) { + ytCommand(iframe, "mute"); + } else { + ytCommand(iframe, "unMute"); + ytCommand(iframe, "setVolume", [volume]); + } + }, [volume, iframeRef]); + const toggleMute = useCallback(() => { if (volume > 0) { setPremuteVolume(volume); @@ -83,19 +106,23 @@ function VolumeControl() { > - setVolume(Number(e.target.value))} - className="h-1 w-16 cursor-pointer appearance-none rounded-full bg-muted-foreground/20 accent-red-400 [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-400 [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:hover:scale-125 [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-red-400" - aria-label="Volume" - /> - - {volume} - +
+ {/* Filled track behind the slider */} +
+ setVolume(Number(e.target.value))} + className="relative h-1 w-16 cursor-pointer appearance-none rounded-full bg-muted-foreground/20 [&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10 [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-400 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:hover:scale-125 [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-red-400 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted-foreground/20" + aria-label="Volume" + /> +
); } @@ -115,14 +142,27 @@ function CustomSlotEditor({ const { setCustomSlot } = useYouTubePlayerStore(); const [name, setName] = useState(existingSlot?.name ?? ""); const [url, setUrl] = useState(existingSlot?.url ?? ""); - const nameRef = useRef(null); + const [error, setError] = useState(null); + const urlRef = useRef(null); + + // Auto-focus the URL field on mount + useEffect(() => { + urlRef.current?.focus(); + }, []); const handleSave = useCallback(() => { const trimmedName = name.trim() || `Custom ${slotIndex + 1}`; const trimmedUrl = url.trim(); - if (!trimmedUrl) return; + if (!trimmedUrl) { + setError("Paste a YouTube URL"); + return; + } const parsed = parseYouTubeUrl(trimmedUrl); - if (!parsed) return; + if (!parsed) { + setError("Not a valid YouTube URL or video ID"); + return; + } + setError(null); setCustomSlot(slotIndex, trimmedName, trimmedUrl); onDone(); }, [name, url, slotIndex, setCustomSlot, onDone]); @@ -130,26 +170,39 @@ function CustomSlotEditor({ return (
setName(e.target.value)} - placeholder={`Custom ${slotIndex + 1} name...`} + placeholder={`Name (optional)`} className="rounded-md border border-border/60 bg-background px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground/40 focus:border-red-500/50 focus:outline-none" /> setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + if (error) setError(null); + }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSave(); } + if (e.key === "Escape") { + e.preventDefault(); + onDone(); + } }} placeholder="Paste YouTube URL..." - className="rounded-md border border-border/60 bg-background px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground/40 focus:border-red-500/50 focus:outline-none" + className={cn( + "rounded-md border bg-background px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground/40 focus:outline-none", + error + ? "border-red-500/60 focus:border-red-500/80" + : "border-border/60 focus:border-red-500/50", + )} /> + {error &&

{error}

}
)} @@ -260,6 +358,7 @@ export function YouTubePlayerDrawer() { }} className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" aria-label={minimized ? "Restore player" : "Minimize player"} + title={minimized ? "Restore player" : "Minimize player"} > {minimized ? ( @@ -274,6 +373,7 @@ export function YouTubePlayerDrawer() { onClick={() => setOpen(false)} className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" aria-label="Close YouTube player" + title="Close player" > @@ -300,7 +400,13 @@ export function YouTubePlayerDrawer() { : "text-muted-foreground/70 hover:bg-accent hover:text-foreground", )} > - + {selectedIndex === idx ? ( + + + + ) : ( + + )} {pl.name} ))} @@ -343,7 +449,13 @@ export function YouTubePlayerDrawer() { setExpanded(false); }} > - + {selectedIndex === globalIdx ? ( + + + + ) : ( + + )} {slot.name} ); })} @@ -398,27 +510,27 @@ export function YouTubePlayerDrawer() { aria-hidden={minimized} >