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:
Nuwan 2026-01-16 19:05:13 +05:30
parent 4fa9ed17fd
commit fa5f8c97cb
2 changed files with 494 additions and 0 deletions

View File

@ -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;
}

View File

@ -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;