diff --git a/jam-ui/src/components/client/JKSessionMetronomePlayer.css b/jam-ui/src/components/client/JKSessionMetronomePlayer.css new file mode 100644 index 000000000..eb40f67e5 --- /dev/null +++ b/jam-ui/src/components/client/JKSessionMetronomePlayer.css @@ -0,0 +1,219 @@ +.metronome-player-popup { + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif; + background-color: #f8f9fa; + min-width: 400px; +} + +.metronome-player-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #dee2e6; +} + +.metronome-player-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #212529; +} + +.metronome-close-btn { + background: none; + border: none; + font-size: 1.25rem; + color: #6c757d; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s; +} + +.metronome-close-btn:hover { + background-color: #e9ecef; + color: #495057; +} + +.metronome-player-body { + padding: 10px 0; +} + +.metronome-player-controls { + display: flex; + flex-direction: column; + gap: 20px; +} + +.metronome-error { + padding: 10px; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 4px; + font-size: 0.875rem; +} + +.metronome-control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.metronome-label { + font-weight: 600; + font-size: 0.875rem; + color: #495057; + margin: 0; +} + +.metronome-tempo-controls { + display: flex; + gap: 12px; + align-items: center; +} + +.metronome-slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: #dee2e6; + outline: none; + -webkit-appearance: none; +} + +.metronome-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #007bff; + cursor: pointer; + transition: background 0.2s; +} + +.metronome-slider::-webkit-slider-thumb:hover { + background: #0056b3; +} + +.metronome-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: #007bff; + cursor: pointer; + border: none; + transition: background 0.2s; +} + +.metronome-slider::-moz-range-thumb:hover { + background: #0056b3; +} + +.metronome-slider:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.metronome-input { + width: 80px; + padding: 6px 10px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 0.875rem; + text-align: center; +} + +.metronome-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.metronome-input:disabled { + background-color: #e9ecef; + opacity: 0.6; + cursor: not-allowed; +} + +.metronome-select { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 0.875rem; + background-color: #fff; + cursor: pointer; + transition: border-color 0.2s; +} + +.metronome-select:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.metronome-select:disabled { + background-color: #e9ecef; + opacity: 0.6; + cursor: not-allowed; +} + +.metronome-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.875rem; + color: #495057; + margin: 0; +} + +.metronome-checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.metronome-checkbox-label input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.metronome-actions { + display: flex; + gap: 10px; + margin-top: 10px; + padding-top: 20px; + border-top: 1px solid #dee2e6; +} + +.metronome-actions button { + flex: 1; + padding: 10px 16px; + font-size: 0.875rem; + font-weight: 600; + border-radius: 4px; + transition: all 0.2s; +} + +/* Modal-specific styles */ +.metronome-player-modal .modal-content { + border-radius: 8px; +} + +.metronome-player-modal .modal-header { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; +} + +.metronome-player-modal .modal-body { + padding: 20px; +} diff --git a/jam-ui/src/components/client/JKSessionMetronomePlayer.js b/jam-ui/src/components/client/JKSessionMetronomePlayer.js new file mode 100644 index 000000000..e7b075b3a --- /dev/null +++ b/jam-ui/src/components/client/JKSessionMetronomePlayer.js @@ -0,0 +1,275 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import './JKSessionMetronomePlayer.css'; + +const METRO_SOUND_LOOKUP = { + 0: "BuiltIn", + 1: "SineWave", + 2: "Beep", + 3: "Click", + 4: "Kick", + 5: "Snare", + 6: "MetroFile" +}; + +const METRO_SOUND_REVERSE_LOOKUP = { + "BuiltIn": 0, + "SineWave": 1, + "Beep": 2, + "Click": 3, + "Kick": 4, + "Snare": 5, + "MetroFile": 6 +}; + +const JKSessionMetronomePlayer = ({ + isOpen, + onClose, + metronomeState, + jamClient, + session, + currentUser, + isPopup = false +}) => { + // Local state for controls + const [bpm, setBpm] = useState(120); + const [sound, setSound] = useState(2); // Default: Beep + const [meter, setMeter] = useState(1); + const [cricket, setCricket] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [error, setError] = useState(null); + + // Visibility tracking for performance optimization + const [isVisible, setIsVisible] = useState(true); + + // Track page visibility for performance optimization + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(!document.hidden); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); + + // Initialize state from metronomeState prop + useEffect(() => { + if (metronomeState) { + console.log('[Metronome] Initializing from state:', metronomeState); + if (metronomeState.bpm != null) setBpm(metronomeState.bpm); + if (metronomeState.sound != null) setSound(metronomeState.sound); + if (metronomeState.meter != null) setMeter(metronomeState.meter); + if (metronomeState.cricket != null) setCricket(metronomeState.cricket); + } + }, [metronomeState]); + + // Handle BPM change + const handleBpmChange = useCallback((value) => { + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && numValue >= 40 && numValue <= 300) { + setBpm(numValue); + } + }, []); + + // Handle sound change + const handleSoundChange = useCallback((value) => { + const numValue = parseInt(value, 10); + if (!isNaN(numValue)) { + setSound(numValue); + } + }, []); + + // Handle meter change + const handleMeterChange = useCallback((value) => { + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && numValue >= 1 && numValue <= 12) { + setMeter(numValue); + } + }, []); + + // Handle cricket toggle + const handleCricketChange = useCallback((checked) => { + setCricket(checked); + }, []); + + // Apply settings to backend + const handleApplySettings = useCallback(async () => { + if (!jamClient) { + setError('Audio engine not available'); + return; + } + + setIsApplying(true); + setError(null); + + try { + console.log('[Metronome] Applying settings:', { + bpm, + sound: METRO_SOUND_LOOKUP[sound], + meter, + cricket: cricket ? 1 : 0 + }); + + // Call jamClient.SessionSetMetronome with current settings + await jamClient.SessionSetMetronome( + bpm, + METRO_SOUND_LOOKUP[sound], + meter, + cricket ? 1 : 0 + ); + + console.log('[Metronome] Settings applied successfully'); + } catch (err) { + console.error('[Metronome] Error applying settings:', err); + setError('Failed to apply metronome settings'); + } finally { + setIsApplying(false); + } + }, [jamClient, bpm, sound, meter, cricket]); + + // Handle close + const handleClose = useCallback(() => { + console.log('[Metronome] Closing player'); + if (onClose) { + onClose(); + } + }, [onClose]); + + // Render controls + const renderControls = () => ( +