Spaces:
Running
Running
| import { useRef, useState, useEffect } from "react"; | |
| import { Play, Pause, Check, Volume2, Music2, X, Waves, Settings2 } from "lucide-react"; | |
| import { MG_MUSIC, SFX_STYLES } from "../remotion/mg/catalog.mjs"; | |
| import { useStore } from "../store"; | |
| type Track = { id: string; label: string; file: string; genre: string; mood: string; bpm: number; durationSec: number; blurb: string; gradient: string }; | |
| const TRACKS = MG_MUSIC as Track[]; | |
| type SfxStyle = { id: string; label: string; icon: string; blurb: string }; | |
| const STYLES = SFX_STYLES as SfxStyle[]; | |
| // a representative cue to preview each style (so the chip "sounds like" what it does) | |
| const STYLE_DEMO: Record<string, string> = { designed: "swoosh", cinematic: "whoosh", clean: "pop", punchy: "impact", soft: "chime", retro: "glitch", off: "" }; | |
| // a deterministic static "waveform" silhouette for a card at rest (no audio analysis needed) | |
| const restBar = (i: number) => 22 + (Math.sin(i * 1.7) * 0.5 + 0.5) * 64 + ((i * 41) % 23); | |
| // The Background-music panel β browse the library, preview any track, pick one (or "No music"). | |
| // The chosen track loops softly under the whole video (wired in EditorComposition). | |
| export function MusicLibrary() { | |
| const music = useStore((s) => s.music); | |
| const musicVolume = useStore((s) => s.musicVolume); | |
| const setMusic = useStore((s) => s.setMusic); | |
| const setMusicVolume = useStore((s) => s.setMusicVolume); | |
| const musicFadeInMs = useStore((s) => s.musicFadeInMs); | |
| const musicFadeOutMs = useStore((s) => s.musicFadeOutMs); | |
| const setMusicFade = useStore((s) => s.setMusicFade); | |
| const sfxStyle = useStore((s) => s.sfxStyle); | |
| const setSfxStyle = useStore((s) => s.setSfxStyle); | |
| const [preview, setPreview] = useState<string | null>(null); | |
| const audioRef = useRef<HTMLAudioElement | null>(null); | |
| const sfxRef = useRef<HTMLAudioElement | null>(null); | |
| const playSfx = (file: string) => { if (!file) return; try { sfxRef.current?.pause(); sfxRef.current = new Audio("/sfx/" + file + ".mp3"); sfxRef.current.volume = 0.85; void sfxRef.current.play(); } catch { /* ignore */ } }; | |
| const stop = () => { audioRef.current?.pause(); audioRef.current = null; setPreview(null); }; | |
| const togglePreview = (m: Track) => { | |
| if (preview === m.id) { stop(); return; } | |
| audioRef.current?.pause(); | |
| const a = new Audio("/music/" + m.file); | |
| a.loop = true; a.volume = 0.9; | |
| a.play().catch(() => { /* autoplay blocked until a gesture β this IS a gesture, so fine */ }); | |
| a.onended = () => setPreview(null); | |
| audioRef.current = a; setPreview(m.id); | |
| }; | |
| useEffect(() => () => { audioRef.current?.pause(); }, []); | |
| const selected = TRACKS.find((m) => m.id === music) || null; | |
| return ( | |
| <div className="flex h-full flex-col"> | |
| <style>{`@keyframes elgeq{0%,100%{transform:scaleY(0.28)}50%{transform:scaleY(1)}}.elg-eq{transform-origin:bottom;animation:elgeq .75s ease-in-out infinite}`}</style> | |
| <div className="px-3 pt-3 pb-2"> | |
| <div className="flex items-center gap-1.5 text-[12px] font-semibold text-fg"><Waves size={13} className="text-primary" /> Sound & Music</div> | |
| <div className="mt-0.5 text-[10px] text-fg-subtle">Control every scene's sound effects and the background track in one place.</div> | |
| </div> | |
| <div className="flex-1 space-y-2 overflow-y-auto px-3 pb-3"> | |
| {/* ββ Motion-graphics SOUND STYLE β re-skins every scene's entrance sound at once ββ */} | |
| <div className="rounded-xl border border-border bg-white/[0.02] p-2.5"> | |
| <div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-fg"><Settings2 size={12} className="text-primary" /> Motion sound</div> | |
| <div className="mb-2 text-[10px] text-fg-subtle">Every scene plays a sound on its entrance. Pick a style to re-skin them all β tap to hear it.</div> | |
| <div className="grid grid-cols-2 gap-1.5"> | |
| {STYLES.map((s) => { | |
| const on = (sfxStyle || "designed") === s.id; | |
| return ( | |
| <button key={s.id} onClick={() => { setSfxStyle(s.id); playSfx(STYLE_DEMO[s.id]); }} title={s.blurb} | |
| className={"flex items-center gap-1.5 rounded-lg border px-2 py-1.5 text-left transition " + (on ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")}> | |
| <span className="text-[14px] leading-none">{s.icon}</span> | |
| <span className="min-w-0"> | |
| <span className="block truncate text-[11px] font-medium text-fg">{s.label}</span> | |
| <span className="block truncate text-[9px] text-fg-subtle">{s.blurb}</span> | |
| </span> | |
| {on && <Check size={12} className="ml-auto shrink-0 text-primary" />} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <div className="mt-2 text-[9.5px] text-fg-subtle">Fine-tune one scene: select it on the timeline β the inspector's <span className="text-fg-muted">Sound</span> section (or click its π badge).</div> | |
| </div> | |
| {/* ββ BACKGROUND MUSIC ββ */} | |
| <div className="flex items-center gap-1.5 pt-1 text-[11px] font-semibold text-fg"><Music2 size={12} className="text-primary" /> Background music <span className="text-[9px] font-normal text-fg-subtle">β loops under the video</span></div> | |
| {/* No music */} | |
| <button onClick={() => setMusic("")} | |
| className={"flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition " + (!music ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")}> | |
| <X size={14} className="text-fg-muted" /><span className="text-[12px] text-fg">No music</span> | |
| {!music && <Check size={13} className="ml-auto text-primary" />} | |
| </button> | |
| {TRACKS.map((m) => { | |
| const isSel = music === m.id, isPlaying = preview === m.id; | |
| return ( | |
| <div key={m.id} onClick={() => setMusic(m.id)} title={`Use β${m.label}β β or drag onto the timeline`} | |
| draggable onDragStart={(e) => { e.dataTransfer.setData("application/x-elg-music", m.id); e.dataTransfer.effectAllowed = "copy"; }} | |
| className={"group relative cursor-grab overflow-hidden rounded-xl border transition active:cursor-grabbing " + (isSel ? "border-primary ring-1 ring-primary/50" : "border-border hover:border-primary/60")}> | |
| <div className="absolute inset-0" style={{ background: m.gradient }} /> | |
| <div className="absolute inset-0 bg-black/40 transition group-hover:bg-black/30" /> | |
| <div className="relative p-3"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-1.5 truncate text-[13px] font-semibold text-white drop-shadow-sm"> | |
| {m.label}{isSel && <Check size={13} className="shrink-0 text-white" />} | |
| </div> | |
| <div className="truncate text-[10px] text-white/85">{m.genre} Β· {m.mood}</div> | |
| </div> | |
| <button onClick={(e) => { e.stopPropagation(); togglePreview(m); }} title={isPlaying ? "Stop preview" : "Preview"} | |
| className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/95 text-black shadow-lg transition hover:scale-105 hover:bg-white"> | |
| {isPlaying ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />} | |
| </button> | |
| </div> | |
| {/* live equalizer / waveform */} | |
| <div className="mt-2.5 flex h-6 items-end gap-[2.5px]"> | |
| {Array.from({ length: 32 }).map((_, i) => ( | |
| <span key={i} className={"flex-1 rounded-sm " + (isPlaying ? "elg-eq bg-white/85" : "bg-white/55")} | |
| style={isPlaying ? { height: "100%", animationDelay: `${(i % 9) * 70}ms`, animationDuration: `${0.6 + (i % 5) * 0.1}s` } : { height: `${restBar(i)}%` }} /> | |
| ))} | |
| </div> | |
| <div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-white/85"> | |
| <span className="truncate">{m.blurb}</span> | |
| <span className="shrink-0 tabular-nums">{m.bpm} BPM Β· {m.durationSec}s</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* volume + status footer (only when a track is chosen) */} | |
| {selected && ( | |
| <div className="border-t border-border px-3 py-2.5"> | |
| <div className="flex items-center gap-2"> | |
| <Volume2 size={14} className="shrink-0 text-fg-muted" /> | |
| <input type="range" min={0} max={1} step={0.05} value={musicVolume} onChange={(e) => setMusicVolume(Number(e.target.value))} className="h-1 flex-1 accent-primary" /> | |
| <span className="w-7 text-right text-[10px] tabular-nums text-fg-muted">{Math.round(musicVolume * 100)}</span> | |
| </div> | |
| <div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1"> | |
| <div className="flex items-center gap-1.5"><span className="w-12 shrink-0 text-[10px] text-fg-subtle">Fade in</span><input type="range" min={0} max={4000} step={100} value={musicFadeInMs} onChange={(e) => setMusicFade(Number(e.target.value), musicFadeOutMs)} className="h-1 flex-1 accent-primary" /><span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-fg-muted">{(musicFadeInMs / 1000).toFixed(1)}s</span></div> | |
| <div className="flex items-center gap-1.5"><span className="w-12 shrink-0 text-[10px] text-fg-subtle">Fade out</span><input type="range" min={0} max={4000} step={100} value={musicFadeOutMs} onChange={(e) => setMusicFade(musicFadeInMs, Number(e.target.value))} className="h-1 flex-1 accent-primary" /><span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-fg-muted">{(musicFadeOutMs / 1000).toFixed(1)}s</span></div> | |
| </div> | |
| <div className="mt-1.5 truncate text-[10px] text-fg-subtle"><span className="text-fg-muted">{selected.label}</span> loops under your video, fading in & out. Pick βNo musicβ to remove.</div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |