diff --git a/src/input/AudioEngine.ts b/src/input/AudioEngine.ts index f3b496f..db2fa23 100644 --- a/src/input/AudioEngine.ts +++ b/src/input/AudioEngine.ts @@ -7,11 +7,14 @@ export interface AudioEngine { readonly analyserNode: AnalyserNode readonly mode: AudioMode readonly isActive: boolean + readonly isPaused: boolean connectMic(): Promise connectTabCapture(): Promise connectFile(file: File): Promise connectSpotify(stream: MediaStream): void disconnect(): void + pause(): void + resume(): void } export function createAudioEngine(bus: Bus): AudioEngine { @@ -21,6 +24,7 @@ export function createAudioEngine(bus: Bus): AudioEngine { let stream: MediaStream | null = null let audioElement: HTMLAudioElement | null = null let mode: AudioMode = null + let paused = false function ensureContext(): { ctx: AudioContext; analyser: AnalyserNode } { if (!ctx) { @@ -33,6 +37,7 @@ export function createAudioEngine(bus: Bus): AudioEngine { } function cleanupSource() { + paused = false if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null } if (audioElement) { audioElement.pause(); audioElement.src = ''; audioElement = null } if (source) { try { source.disconnect() } catch { /* ignore */ } source = null } @@ -44,6 +49,7 @@ export function createAudioEngine(bus: Bus): AudioEngine { get analyserNode() { return ensureContext().analyser }, get mode() { return mode }, get isActive() { return mode !== null }, + get isPaused() { return paused }, async connectMic() { const { ctx: c, analyser: a } = ensureContext() @@ -112,6 +118,26 @@ export function createAudioEngine(bus: Bus): AudioEngine { mode = null bus.emit('audio:disconnected', undefined as void) }, + + pause() { + if (!mode || paused) return + paused = true + if (audioElement) { + audioElement.pause() + } else { + void ctx?.suspend() + } + }, + + resume() { + if (!mode || !paused) return + paused = false + if (audioElement) { + void audioElement.play() + } else { + void ctx?.resume() + } + }, } return engine diff --git a/src/ui/App.ts b/src/ui/App.ts index 2be3192..22665dc 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -55,8 +55,9 @@ export function AppUI({ store, app }: { store: Store; app: AppInterface }) { bands: Float32Array | null analysis: NonNullable[0]['analysis']> | null mode: string | null + isPaused: boolean lfoPhases: number[] - }>({ bands: null, analysis: null, mode: null, lfoPhases: [] }) + }>({ bands: null, analysis: null, mode: null, isPaused: false, lfoPhases: [] }) // Poll audio state at ~15fps for meters useEffect(() => { @@ -69,17 +70,20 @@ export function AppUI({ store, app }: { store: Store; app: AppInterface }) { const lfoPhases = app.getLFOPhases() const analyser = app.audioAnalyser + const engineMode = app.audioEngine.mode + const enginePaused = app.audioEngine.isPaused if (analyser) { setAudioLive({ bands: analyser.getBands(), analysis: analyser.getAnalysis(), - mode: app.audioEngine.mode, + mode: engineMode, + isPaused: enginePaused, lfoPhases, }) } else { setAudioLive(prev => { - if (prev.mode !== app.audioEngine.mode || prev.lfoPhases !== lfoPhases) { - return { bands: null, analysis: null, mode: app.audioEngine.mode, lfoPhases } + if (prev.mode !== engineMode || prev.isPaused !== enginePaused || prev.lfoPhases !== lfoPhases) { + return { bands: null, analysis: null, mode: engineMode, isPaused: enginePaused, lfoPhases } } return prev }) @@ -160,6 +164,14 @@ export function AppUI({ store, app }: { store: Store; app: AppInterface }) { app.audioEngine.disconnect() }, [app]) + const handlePause = useCallback(() => { + app.audioEngine.pause() + }, [app]) + + const handleResume = useCallback(() => { + app.audioEngine.resume() + }, [app]) + const handleYouTubeLoad = useCallback((url: string) => { app.youtubePlayer.loadVideo(url) }, [app]) @@ -300,6 +312,12 @@ export function AppUI({ store, app }: { store: Store; app: AppInterface }) { return html` <${PanelToggle} onClick=${handleTogglePanel} /> + ${audioLive.mode !== null ? html` +
+ ${audioLive.isPaused ? '▶' : '⏸'} +
+ ` : null} <${Panel} tabs=${TABS} activeTab=${state.activeTab} diff --git a/src/ui/styles.css b/src/ui/styles.css index 12f7ef9..5b856bc 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -18,8 +18,11 @@ canvas#fx{position:fixed;top:0;left:0;z-index:2;pointer-events:none;} .tab.active{color:var(--ac);border-bottom-color:var(--ac);} .tc{display:none;flex:1;overflow-y:auto;padding:14px 12px;scrollbar-width:thin;scrollbar-color:var(--fg2) transparent;} .tc.active{display:block;} -#pt{position:fixed;top:16px;right:16px;z-index:200;width:36px;height:36px;background:var(--pbg);border:1px solid var(--pbr);backdrop-filter:blur(20px);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.3s;opacity:0;} -body:hover #pt,body.su #pt{opacity:1;} +#pt{position:fixed;top:16px;right:16px;z-index:200;width:36px;height:36px;background:var(--pbg);border:1px solid var(--pbr);backdrop-filter:blur(20px);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.3s;opacity:1;} +:fullscreen #pt,:-webkit-full-screen #pt{opacity:0;pointer-events:none;} +#pp{position:fixed;top:16px;left:16px;z-index:200;width:36px;height:36px;background:var(--pbg);border:1px solid var(--pbr);backdrop-filter:blur(20px);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;transition:all 0.3s;user-select:none;} +#pp:hover{background:rgba(255,255,255,0.06);} +:fullscreen #pp,:-webkit-full-screen #pp{opacity:0;pointer-events:none;} #pt:hover{background:rgba(255,255,255,0.06);cursor:pointer;} #pt svg{width:16px;height:16px;stroke:var(--fd);fill:none;stroke-width:1.5;} #panel.open~#pt{right:352px;}