diff --git a/vite/src/pipedal/MixerScenePanel.tsx b/vite/src/pipedal/MixerScenePanel.tsx new file mode 100644 index 0000000..e7ee7a3 --- /dev/null +++ b/vite/src/pipedal/MixerScenePanel.tsx @@ -0,0 +1,465 @@ +// MixerScenePanel — Scene management for the digital mixer. +// +// Displays 8 numbered scene slots (like a hardware digital mixer). +// Each slot can store/recall a full mixer state snapshot. +// Uses backend mixerSaveScene / mixerLoadScene / mixerGetScenes WebSocket messages. +// +// The panel can be embedded in the PerformanceView or used standalone. + +import React from 'react'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Tooltip from '@mui/material/Tooltip'; +import SaveIcon from '@mui/icons-material/Save'; +import RestoreIcon from '@mui/icons-material/Restore'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; + +export const MAX_SCENES = 8; + +export interface MixerSceneInfo { + id: number; + name: string; +} + +interface MixerScenePanelProps { + /** If true, renders without external chrome (for embedding) */ + compact?: boolean; +} + +interface MixerScenePanelState { + scenes: MixerSceneInfo[]; + selectedId: number | null; + loading: boolean; + error: string | null; + /** Save dialog state */ + saveDialogOpen: boolean; + saveName: string; + saving: boolean; + /** Confirm dialog for load */ + confirmLoadId: number | null; + confirmLoadName: string; +} + +export default class MixerScenePanel extends React.Component { + private model: PiPedalModel; + private refreshTimer: ReturnType | null = null; + + constructor(props: MixerScenePanelProps) { + super(props); + this.model = PiPedalModelFactory.getInstance(); + this.state = { + scenes: [], + selectedId: null, + loading: false, + error: null, + saveDialogOpen: false, + saveName: '', + saving: false, + confirmLoadId: null, + confirmLoadName: '', + }; + + this.refreshScenes = this.refreshScenes.bind(this); + this.handleSaveClick = this.handleSaveClick.bind(this); + this.handleSaveDialogClose = this.handleSaveDialogClose.bind(this); + this.handleSaveConfirm = this.handleSaveConfirm.bind(this); + this.handleSlotClick = this.handleSlotClick.bind(this); + this.handleLoadConfirm = this.handleLoadConfirm.bind(this); + this.handleLoadCancel = this.handleLoadCancel.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + } + + componentDidMount() { + this.refreshScenes(); + // Poll for scene updates from other clients + this.refreshTimer = setInterval(this.refreshScenes, 5000); + } + + componentWillUnmount() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + async refreshScenes() { + if (this.state.loading) return; + try { + this.setState({ loading: true, error: null }); + const raw = await this.model.getMixerScenes(); + const parsed = JSON.parse(raw); + const sceneList: MixerSceneInfo[] = (parsed.scenes || []).map((s: any) => ({ + id: s.id, + name: s.name || `Scene ${s.id}`, + })); + // Sort by ID for consistent display + sceneList.sort((a, b) => a.id - b.id); + this.setState({ scenes: sceneList, loading: false }); + } catch (err: any) { + this.setState({ loading: false, error: err?.message || 'Failed to load scenes' }); + } + } + + /** Get scene for a visual slot (0..7). Slots wrap around the first 8 scenes. */ + getSlotScene(slot: number): MixerSceneInfo | null { + return this.state.scenes[slot] ?? null; + } + + handleSaveClick() { + this.setState({ + saveDialogOpen: true, + saveName: `Scene ${this.state.scenes.length + 1}`, + saving: false, + }); + } + + handleSaveDialogClose() { + this.setState({ saveDialogOpen: false, saveName: '' }); + } + + async handleSaveConfirm() { + const name = this.state.saveName.trim(); + if (!name) return; + + this.setState({ saving: true }); + try { + await this.model.saveMixerScene(name); + this.setState({ saveDialogOpen: false, saveName: '', saving: false }); + await this.refreshScenes(); + } catch (err: any) { + this.setState({ + saving: false, + error: err?.message || 'Failed to save scene', + }); + } + } + + handleSlotClick(slot: number) { + const scene = this.getSlotScene(slot); + if (!scene) { + // Empty slot — save dialog + this.setState({ + saveDialogOpen: true, + saveName: `Scene ${slot + 1}`, + }); + return; + } + // Confirm load + this.setState({ + confirmLoadId: scene.id, + confirmLoadName: scene.name, + }); + } + + async handleLoadConfirm() { + const id = this.state.confirmLoadId; + if (id === null) return; + + try { + await this.model.loadMixerScene(id); + this.setState({ confirmLoadId: null, confirmLoadName: '' }); + } catch (err: any) { + this.setState({ + confirmLoadId: null, + confirmLoadName: '', + error: err?.message || 'Failed to load scene', + }); + } + } + + handleLoadCancel() { + this.setState({ confirmLoadId: null, confirmLoadName: '' }); + } + + async handleDeleteClick(slot: number, e: React.MouseEvent) { + e.stopPropagation(); + const scene = this.getSlotScene(slot); + if (!scene) return; + + try { + // Use mixerLoadScene with negative ID as a delete signal, + // or just skip delete since there's no backend handler for it. + // For now, we'll hide slots by calling delete with a workaround. + // Actually, let's check if mixerDeleteScene exists... + // Since it doesn't, we'll just call the backend to overwrite the scene. + this.setState({ error: 'Scene deletion: use Save to overwrite slots.' }); + setTimeout(() => this.setState({ error: null }), 3000); + } catch (err: any) { + this.setState({ error: err?.message || 'Failed to delete scene' }); + } + } + + renderSlot(slot: number) { + const scene = this.getSlotScene(slot); + const isUsed = scene !== null; + + return ( +
this.handleSlotClick(slot)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + width: 80, + height: 64, + margin: 2, + borderRadius: 8, + cursor: 'pointer', + background: isUsed + ? 'rgba(167, 112, 228, 0.2)' // primary color tint + : 'rgba(255, 255, 255, 0.05)', + border: isUsed + ? '1px solid rgba(167, 112, 228, 0.5)' + : '1px dashed rgba(255, 255, 255, 0.15)', + transition: 'all 0.15s ease', + position: 'relative', + userSelect: 'none', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = isUsed + ? 'rgba(167, 112, 228, 0.35)' + : 'rgba(255, 255, 255, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = isUsed + ? 'rgba(167, 112, 228, 0.2)' + : 'rgba(255, 255, 255, 0.05)'; + }} + title={isUsed ? `Load: ${scene!.name}` : 'Save current state here'} + > + + {slot + 1} + + + {isUsed ? ( + <> + + {scene!.name} + +
+ + { + e.stopPropagation(); + this.handleSlotClick(slot); + }} + > + + + + + { + e.stopPropagation(); + this.setState({ + saveDialogOpen: true, + saveName: scene!.name, + }); + }} + > + + + +
+ + ) : ( + + Empty + + )} +
+ ); + } + + render() { + const { loading, error, saveDialogOpen, saveName, saving, confirmLoadId, confirmLoadName } = this.state; + + return ( +
+ {/* Header */} +
+ + Scenes + +
+ {loading && ( + + ... + + )} + + + +
+
+ + {/* Error display */} + {error && ( +
+ + + {error} + +
+ )} + + {/* Scene slots grid */} +
+ {Array.from({ length: MAX_SCENES }, (_, i) => this.renderSlot(i))} +
+ + {/* Save Dialog */} + + Save Mixer Scene + + this.setState({ saveName: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') this.handleSaveConfirm(); + }} + /> + + + + + + + + {/* Load Confirm Dialog */} + + Load Scene + + + Restore scene "{confirmLoadName}"? + This will overwrite the current mixer settings. + + + + + + + +
+ ); + } +} diff --git a/vite/src/pipedal/mixer/MixerScenePanel.tsx b/vite/src/pipedal/mixer/MixerScenePanel.tsx index a16460d..7d703b5 100644 --- a/vite/src/pipedal/mixer/MixerScenePanel.tsx +++ b/vite/src/pipedal/mixer/MixerScenePanel.tsx @@ -6,7 +6,7 @@ // // Embedded in the MixerPage toolbar area. -import React, { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; @@ -18,7 +18,6 @@ import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; import Tooltip from "@mui/material/Tooltip"; import Save from "@mui/icons-material/Save"; -import Restore from "@mui/icons-material/Restore"; import Refresh from "@mui/icons-material/Refresh"; import Alert from "@mui/material/Alert"; import type { MixerWsHandle } from "./useMixerWS";