import React, { useEffect, useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, Checkbox, Typography, Select, MenuItem, FormControl, InputLabel, Divider, IconButton, Tooltip } from '@mui/material'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import StopIcon from '@mui/icons-material/Stop'; // Add this import for the stop button import MusicNoteIcon from '@mui/icons-material/MusicNote'; import AM32_Rtttl from './am32_rtttl'; // Updated import to match class name import { useTranslation } from './i18n/LanguageContext'; const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => { // Add a new state for tracking whether a tune is currently playing const [isPlaying, setIsPlaying] = useState(false); // Add to your existing state variables const [value, setValue] = useState(null); const [previewTune, setPreviewTune] = useState(''); // For tune preview const [selectedPreset, setSelectedPreset] = useState(""); // Move this here from renderRTTLEditor const [errorMessage, setErrorMessage] = useState(''); // For validation error messages const [isValid, setIsValid] = useState(true); // Add a new state variable to track validation status const [paramName, setParamName] = useState(""); // Add paramName to the component state const { t } = useTranslation(); useEffect(() => { const localNode = window.localNode; if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return; const param = localNode.nodeParams[nodeId][paramIndex]; const currentParamName = param.fields.name.toString(); // Set the param name in state so it's available to the whole component setParamName(currentParamName); const paramValueField = param.fields.value.msg.unionField; // Different handling based on value type let paramValue; if (param.fields.value.msg.fields.string_value !== undefined) { // For string values, use toString() directly paramValue = paramValueField.toString(); } else { // For other types (int, float, bool), use .value paramValue = paramValueField.value; } // Special handling for STARTUP_TUNE parameter if (currentParamName === "STARTUP_TUNE" && param.fields.value.msg.fields.string_value !== undefined) { try { // Get the binary string value const binaryString = paramValue; // Convert binary string to Uint8Array const binaryData = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { binaryData[i] = binaryString.charCodeAt(i); } // Convert to RTTTL format const rtttlString = AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune"); setValue(rtttlString); } catch (err) { console.error("Error converting binary data to RTTTL:", err); setValue(""); // Set empty string on error } } else { setValue(paramValue); } }, [nodeId, paramIndex]); // Add nodeId to dependencies const handleSave = () => { const localNode = window.localNode; let valueToSave = value; // If this is the STARTUP_TUNE parameter, convert RTTTL string to byte array if (paramName === "STARTUP_TUNE") { console.log("Converting RTTTL tune:", value); try { // Validate RTTTL string format const rtttlValue = value || ""; const isValidFormat = rtttlValue && rtttlValue.includes(':') && rtttlValue.split(':').length === 3; let result; // Declare result variable outside the if/else blocks if (!isValidFormat) { // Warn user but continue with a default tune setErrorMessage(t('edit.rtttl_warning')); // Continue with a minimal valid RTTTL string const tuneToParse = "Empty:d=4,o=5,b=120:"; result = AM32_Rtttl.to_am32_startup_melody(tuneToParse); } else { // Format is valid, proceed normally setErrorMessage(''); // Clear any previous errors result = AM32_Rtttl.to_am32_startup_melody(rtttlValue); } // Convert to binary string after we have the result const binaryString = String.fromCharCode.apply(null, result.data); valueToSave = binaryString; // For debug purposes, show numeric values console.log("Binary array values:", Array.from(result.data).slice(0, 30)); } catch (err) { console.error("Error converting RTTTL to binary:", err); setErrorMessage(t('edit.error_saving', { error: err.message || t('edit.unknown') })); // Provide an empty binary string (all zeros) as fallback const emptyArray = new Uint8Array(128); valueToSave = String.fromCharCode.apply(null, emptyArray); } } localNode.setNodeParam(nodeId, paramIndex, valueToSave); onClose(); }; // Modify handlePlayTune function to toggle play/stop const handlePlayTune = () => { // If already playing, stop the melody if (isPlaying) { AM32_Rtttl.stopMelody(); setIsPlaying(false); return; } const tuneToPlay = value || ''; try { // First validate that the tune has the basic RTTTL format (name:defaults:notes) if (!tuneToPlay || !tuneToPlay.includes(':') || tuneToPlay.split(':').length !== 3) { setErrorMessage(t('edit.rtttl_invalid')); return; } // Clear any previous error when successful setErrorMessage(''); // Stop any currently playing tune before starting a new one AM32_Rtttl.stopMelody(); // Play the new tune AM32_Rtttl.playMelody(tuneToPlay); setPreviewTune(tuneToPlay); setIsPlaying(true); // Set up an event listener to detect when audio context is closed or ends const estimatedDuration = estimateTuneDuration(tuneToPlay); setTimeout(() => { setIsPlaying(false); }, estimatedDuration + 500); // Add a small buffer } catch (err) { console.error("Error playing tune:", err); setErrorMessage(t('edit.error_playing', { error: err.message || t('edit.unknown') })); setIsPlaying(false); } }; // Add a helper function to estimate tune duration const estimateTuneDuration = (rtttlString) => { try { if (!rtttlString) return 2000; // Default duration if no tune const parts = rtttlString.split(':'); if (parts.length !== 3) return 5000; // Default if format is wrong const defaults = parts[1]; // Extract BPM from defaults const bpmMatch = /b=(\d+)/i.exec(defaults); const bpm = bpmMatch ? parseInt(bpmMatch[1], 10) : 120; // Count notes in the tune const notes = parts[2].split(','); const noteCount = notes.length; // Rough calculation: (60000 / bpm) gives ms per beat, multiply by estimated beats return Math.min((60000 / bpm) * noteCount * 1.5, 30000); // Cap at 30 seconds } catch (e) { console.warn('Error estimating tune duration:', e); return 5000; // Default fallback } }; const handleSelectPreset = (presetValue) => { setValue(presetValue); }; // Handle apply preset - moved from renderRTTTLEditor const handleApplyPreset = () => { if (selectedPreset) { setValue(selectedPreset); setSelectedPreset(""); // Reset selection after applying } }; // Common RTTTL tunes const rtttlPresets = { "BlueJay": "bluejay:b=570,o=4,d=32:4b,p,4e5,p,4b,p,4f#5,2p,4e5,2b5,8b5", "Nokia Tune": "Nokia:d=4,o=5,b=63:e6,d6,f#,g#,c#6,b,d,e,b,a,c#,e,a", "Mario": "Mario:d=4,o=5,b=125:a,a,a,a,a#,c6,a,g,e,c,d,a#4,c", "Star Wars": "StarWars:d=4,o=5,b=112:8f,8f,8f,2a#.,2f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8d#6,2c6", "Pacman": "Pacman:d=16,o=6,b=140:b5,b,f#,d#,8b,8d#,c,c7,g,f,8c7,8e,b5,b,f#,d#,8b,8d#,c,g,c7,g,8f,8c7", "Indiana": "Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6", "Mission": "Mission:d=16,o=6,b=95:32d,32d#,32d,32d#,32d,32d#,32d,32d#,32d,32d,32d#,32e,32f,32f#,32g,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,a#,g,2d" }; // Fix the validateRtttl function to handle non-string values const validateRtttl = (rtttlString) => { console.log("Validating RTTTL:", rtttlString); // If we're not editing STARTUP_TUNE, don't validate with RTTTL if (paramName !== "STARTUP_TUNE") { return true; } // Convert to string if it's a number or other type const stringValue = String(rtttlString || ''); // Empty strings are valid (to allow clearing the tune) if (!stringValue) { setErrorMessage(''); return true; } // Check for basic RTTTL format const isValidFormat = stringValue.includes(':') && stringValue.split(':').length === 3; if (!isValidFormat) { setErrorMessage(t('edit.rtttl_invalid')); return false; } // Additional validation could be added here setErrorMessage(''); // Clear error if valid return true; }; // Similarly, update the handleValueChange function to ensure we're dealing with strings const handleValueChange = (newValue) => { // For RTTTL special handling - check if it's specifically "STARTUP_TUNE" if (paramName === "STARTUP_TUNE") { setValue(newValue); // Convert to string before validation setIsValid(validateRtttl(String(newValue))); return; } // For all other parameters, don't try to validate with RTTTL const localNode = window.localNode; const param = localNode?.nodeParams?.[nodeId]?.[paramIndex]; if (!param) { setValue(newValue); return; } // Rest of your existing numeric validation... if (param && ( param.fields.value.msg.fields.integer_value !== undefined || param.fields.value.msg.fields.real_value !== undefined )) { // Skip validation for empty strings or non-numeric values during typing if (newValue === '' || isNaN(parseFloat(newValue))) { setValue(newValue); return; } const numericValue = parseFloat(newValue); const min = param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ? param.fields.min_value.msg.unionField.value : null; const max = param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ? param.fields.max_value.msg.unionField.value : null; // Validate against min/max if they exist if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) { setIsValid(false); setErrorMessage(t('edit.value_range', { min: min !== null ? min : '-∞', max: max !== null ? max : '∞' })); } else { setIsValid(true); setErrorMessage(''); } } setValue(newValue); }; // Add useEffect to validate parameter changes useEffect(() => { // Skip validation if value is null or undefined or paramName isn't set yet if (value === null || value === undefined || !paramName) return; const localNode = window.localNode; const param = localNode?.nodeParams?.[nodeId]?.[paramIndex]; if (!param) return; // For RTTTL validation if (paramName === "STARTUP_TUNE") { setIsValid(validateRtttl(value)); return; } // For numeric validation if (param.fields.value.msg.fields.integer_value !== undefined || param.fields.value.msg.fields.real_value !== undefined) { // Skip validation for empty strings or non-numeric values if (value === '' || isNaN(parseFloat(value))) { setIsValid(false); return; } const numericValue = parseFloat(value); const min = param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ? param.fields.min_value.msg.unionField.value : null; const max = param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ? param.fields.max_value.msg.unionField.value : null; // Validate against min/max if they exist if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) { setIsValid(false); setErrorMessage(t('edit.value_range', { min: min !== null ? min : '-∞', max: max !== null ? max : '∞' })); } else { setIsValid(true); setErrorMessage(''); } } else { // For boolean and string types, always valid setIsValid(true); setErrorMessage(''); } }, [value, nodeId, paramIndex, paramName, localNode]); // Update the RTTTL editor to remove the problematic helperText const renderRTTTLEditor = () => { return ( {t('edit.select_preset')} handleValueChange(e.target.value)} fullWidth margin="dense" multiline rows={3} placeholder={t('edit.rtttl_placeholder')} error={!isValid && value !== ''} // Remove helperText to avoid layout issues /> {isPlaying ? : } {/* Add a simple instruction text below the field */} {t('edit.rtttl_instruction')} {t('edit.rtttl_guide_title')} • {t('edit.rtttl_guide_duration')} • {t('edit.rtttl_guide_octave')} • {t('edit.rtttl_guide_tempo')} • {t('edit.rtttl_guide_notes')} • {t('edit.rtttl_guide_example')} ); }; // Update the renderValueEditField function to fix layout issues and improve validation const renderValueEditField = (min, max) => { const param = localNode.nodeParams[nodeId][paramIndex]; let step; // For boolean type if (param.fields.value.msg.fields.boolean_value) { return ( {t('edit.enable_disable')} setValue(e.target.checked ? 1 : 0)} /> ); } // For string type - return null for normal strings, STARTUP_TUNE is handled separately if (param.fields.value.msg.fields.string_value) { return null; } // For numeric types if (param.fields.value.msg.fields.integer_value) { step = 1; } else if (param.fields.value.msg.fields.real_value) { step = 0.01; } else { console.error('Unknown value kind'); } // Check if value is outside limits for the error state const numericValue = parseFloat(value); const isOutOfBounds = (min !== "" && !isNaN(min) && numericValue < min) || (max !== "" && !isNaN(max) && numericValue > max); return ( handleValueChange(e.target.value)} fullWidth margin="dense" helperText={isOutOfBounds ? t('edit.value_range', { min: min !== "" ? min : '-∞', max: max !== "" ? max : '∞' }) : null } /> ); }; if (value === null) return null; const localNode = window.localNode; let param = localNode.nodeParams[nodeId][paramIndex]; if (!param) return null; if (!param.fields) return null; let paramValueField = param.fields.value.msg.unionField; let paramMinValue = ""; if (param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') { paramMinValue = param.fields.min_value.msg.unionField.value; } let paramMaxValue = ""; if (param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') { paramMaxValue = param.fields.max_value.msg.unionField.value; } let paramDefaultValue = ""; if (param.fields.default_value.msg && param.fields.default_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') { paramDefaultValue = param.fields.default_value.msg.unionField.value; } const isBoolean = param.fields.value.msg.fields.boolean_value !== undefined; const isString = param.fields.value.msg.fields.string_value !== undefined; const isRTTTLEditor = paramName === "STARTUP_TUNE"; const renderParamNameField = (name) => ( ); // Replace the renderInfoField function with this enhanced version that handles RTTTL differently const renderInfoField = (label, value, isRtttl = false) => ( {label} {isRtttl ? ( ) : ( {value !== undefined && value !== "" && value !== null ? value : t('edit.unknown')} )} ); return ( {t('edit.title')} {renderParamNameField(paramName)} {!isString && !isRTTTLEditor && renderValueEditField(paramMinValue, paramMaxValue)} {isString && !isRTTTLEditor && ( setValue(e.target.value)} fullWidth margin="dense" multiline={Boolean(value && typeof value === 'string' && value.length > 30)} rows={(value && typeof value === 'string' && value.length > 30) ? 3 : 1} /> )} {isRTTTLEditor && renderRTTTLEditor()} {paramName === "STARTUP_TUNE" && isString ? ( renderInfoField(t('edit.current_rtttl'), (() => { try { // Get the binary string value const binaryString = paramValueField.toString(); // Convert binary string to Uint8Array const binaryData = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { binaryData[i] = binaryString.charCodeAt(i); } // Convert to RTTTL format return AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune"); } catch (err) { console.error("Error converting binary data to RTTTL:", err); return t('edit.error_parsing_melody'); } })(), true) // Pass true to indicate this is an RTTTL value ) : ( renderInfoField( t('edit.current_value'), isBoolean ? (paramValueField.value ? t('edit.true') : t('edit.false')) : isString ? paramValueField.toString() : paramValueField.value ) )} {/* Only show default value when not STARTUP_TUNE */} {paramName !== "STARTUP_TUNE" && renderInfoField(t('edit.default_value'), isBoolean ? (paramDefaultValue ? t('param.true') : t('param.false')) : paramDefaultValue)} {!isBoolean && !isString && !isRTTTLEditor && ( {renderInfoField(t('edit.min_value'), paramMinValue)} {renderInfoField(t('edit.max_value'), paramMaxValue)} )} {errorMessage && ( {errorMessage} )} ); }; export default EditParamModal;