11// MIDI controller settings dialog.
22// Shows connected devices, command mappings with MIDI Learn, and reset option.
33
4+ import { useRef } from 'react' ;
45import { useMidiStore } from '../../stores/useMidiStore' ;
5- import type { MidiCommand , MidiMessageType } from '../../services/midiService' ;
6+ import { useToastStore } from '../../stores/useToastStore' ;
7+ import type { MidiCommand , MidiMapping , MidiMessageType } from '../../services/midiService' ;
68
79interface MidiSettingsDialogProps {
810 onClose : ( ) => void ;
@@ -38,6 +40,9 @@ export function MidiSettingsDialog({ onClose }: MidiSettingsDialogProps) {
3840 const learnTarget = useMidiStore ( ( s ) => s . learnTarget ) ;
3941 const setLearnTarget = useMidiStore ( ( s ) => s . setLearnTarget ) ;
4042 const resetMappings = useMidiStore ( ( s ) => s . resetMappings ) ;
43+ const setMappings = useMidiStore ( ( s ) => s . setMappings ) ;
44+ const addToast = useToastStore ( ( s ) => s . addToast ) ;
45+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
4146
4247 const handleKeyDown = ( e : React . KeyboardEvent ) => {
4348 if ( e . key === 'Escape' ) {
@@ -57,6 +62,55 @@ export function MidiSettingsDialog({ onClose }: MidiSettingsDialogProps) {
5762 await resetMappings ( ) ;
5863 } ;
5964
65+ const handleExport = ( ) => {
66+ const blob = new Blob (
67+ [ JSON . stringify ( mappings , null , 2 ) ] ,
68+ { type : 'application/json' } ,
69+ ) ;
70+ const url = URL . createObjectURL ( blob ) ;
71+ const a = document . createElement ( 'a' ) ;
72+ a . href = url ;
73+ a . download = 'midi-mappings.songlab.json' ;
74+ a . click ( ) ;
75+ URL . revokeObjectURL ( url ) ;
76+ } ;
77+
78+ const handleImport = async ( e : React . ChangeEvent < HTMLInputElement > ) => {
79+ const file = e . target . files ?. [ 0 ] ;
80+ if ( ! file ) return ;
81+
82+ try {
83+ const text = await file . text ( ) ;
84+ const data = JSON . parse ( text ) as MidiMapping [ ] ;
85+
86+ // Validate structure
87+ if ( ! Array . isArray ( data ) || data . length === 0 ) {
88+ addToast ( 'Invalid mapping file: expected a non-empty array' , 'error' ) ;
89+ return ;
90+ }
91+
92+ const isValid = data . every (
93+ ( m ) => m . command && m . type && typeof m . note === 'number' ,
94+ ) ;
95+ if ( ! isValid ) {
96+ addToast ( 'Invalid mapping file: entries are missing required fields' , 'error' ) ;
97+ return ;
98+ }
99+
100+ setMappings ( data ) ;
101+ await import ( '../../services/db' ) . then (
102+ ( db ) => db . setConfig ( 'midiMappings' , data ) ,
103+ ) ;
104+ addToast ( 'MIDI mappings imported' , 'success' ) ;
105+ } catch ( error ) {
106+ console . error ( 'Failed to import MIDI mappings:' , error ) ;
107+ addToast ( 'Failed to import MIDI mappings' , 'error' ) ;
108+ }
109+
110+ // Reset file input so the same file can be re-imported
111+ if ( fileInputRef . current ) fileInputRef . current . value = '' ;
112+ } ;
113+
60114 return (
61115 < div
62116 className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'
@@ -156,15 +210,41 @@ export function MidiSettingsDialog({ onClose }: MidiSettingsDialogProps) {
156210 </ div >
157211
158212 { /* Buttons */ }
159- < div className = 'flex justify-between mt-2' >
160- < button
161- onClick = { handleReset }
162- className = 'px-3 py-1.5 text-xs font-mono text-slate-500 hover:text-slate-300
163- transition-colors'
164- aria-label = 'Reset MIDI mappings to defaults'
165- >
166- Reset defaults
167- </ button >
213+ < div className = 'flex items-center justify-between mt-2' >
214+ < div className = 'flex gap-2' >
215+ < button
216+ onClick = { handleReset }
217+ className = 'px-4 py-1.5 text-xs font-mono bg-slate-700 hover:bg-slate-600
218+ text-slate-200 rounded-lg transition-colors'
219+ aria-label = 'Reset MIDI mappings to defaults'
220+ >
221+ Reset
222+ </ button >
223+ < button
224+ onClick = { handleExport }
225+ className = 'px-4 py-1.5 text-xs font-mono bg-slate-700 hover:bg-slate-600
226+ text-slate-200 rounded-lg transition-colors'
227+ aria-label = 'Export MIDI mappings'
228+ >
229+ Export
230+ </ button >
231+ < button
232+ onClick = { ( ) => fileInputRef . current ?. click ( ) }
233+ className = 'px-4 py-1.5 text-xs font-mono bg-slate-700 hover:bg-slate-600
234+ text-slate-200 rounded-lg transition-colors'
235+ aria-label = 'Import MIDI mappings'
236+ >
237+ Import
238+ </ button >
239+ < input
240+ ref = { fileInputRef }
241+ type = 'file'
242+ accept = '.json'
243+ onChange = { handleImport }
244+ className = 'hidden'
245+ aria-hidden = 'true'
246+ />
247+ </ div >
168248 < button
169249 onClick = { ( ) => {
170250 setLearnTarget ( null ) ;
0 commit comments