feat: add metronome player popup component
Implements JKSessionMetronomePlayer with controls for: - Tempo slider/input (BPM: 40-300) - Sound selector (Beep, Click, Kick, Snare, etc.) - Meter selector (1-12) - Visual metronome toggle (Cricket) - Apply and Close buttons Supports both WindowPortal popup mode and Modal fallback. Uses jamClient.SessionSetMetronome to apply settings. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4fa9ed17fd
commit
fa5f8c97cb
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = () => (
|
||||
<div className="metronome-player-controls">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="metronome-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tempo control */}
|
||||
<div className="metronome-control-group">
|
||||
<label className="metronome-label">Tempo (BPM)</label>
|
||||
<div className="metronome-tempo-controls">
|
||||
<input
|
||||
type="range"
|
||||
className="metronome-slider"
|
||||
min="40"
|
||||
max="300"
|
||||
value={bpm}
|
||||
onChange={(e) => handleBpmChange(e.target.value)}
|
||||
disabled={isApplying}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="metronome-input"
|
||||
min="40"
|
||||
max="300"
|
||||
value={bpm}
|
||||
onChange={(e) => handleBpmChange(e.target.value)}
|
||||
disabled={isApplying}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound selector */}
|
||||
<div className="metronome-control-group">
|
||||
<label className="metronome-label">Sound</label>
|
||||
<select
|
||||
className="metronome-select"
|
||||
value={sound}
|
||||
onChange={(e) => handleSoundChange(e.target.value)}
|
||||
disabled={isApplying}
|
||||
>
|
||||
<option value={2}>Beep</option>
|
||||
<option value={3}>Click</option>
|
||||
<option value={4}>Kick</option>
|
||||
<option value={5}>Snare</option>
|
||||
<option value={0}>Built-in</option>
|
||||
<option value={1}>Sine Wave</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Meter selector */}
|
||||
<div className="metronome-control-group">
|
||||
<label className="metronome-label">Meter</label>
|
||||
<select
|
||||
className="metronome-select"
|
||||
value={meter}
|
||||
onChange={(e) => handleMeterChange(e.target.value)}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Visual metronome (cricket) */}
|
||||
<div className="metronome-control-group">
|
||||
<label className="metronome-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cricket}
|
||||
onChange={(e) => handleCricketChange(e.target.checked)}
|
||||
disabled={isApplying}
|
||||
/>
|
||||
<span>Visual Metronome</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="metronome-actions">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleApplySettings}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={isApplying}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// If popup mode, render directly (for WindowPortal)
|
||||
if (isPopup) {
|
||||
return (
|
||||
<div className="metronome-player-popup">
|
||||
<div className="metronome-player-header">
|
||||
<h3>Metronome Controls</h3>
|
||||
<button
|
||||
className="metronome-close-btn"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="metronome-player-body">
|
||||
{renderControls()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render as Modal (fallback)
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={handleClose} className="metronome-player-modal">
|
||||
<ModalHeader toggle={handleClose}>
|
||||
Metronome Controls
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{renderControls()}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default JKSessionMetronomePlayer;
|
||||
Loading…
Reference in New Issue