feat(05-03): redesign JamTrack player with macOS-style interface
Matches design from screenshot with: - Window chrome with traffic light controls - Circular play/pause and square stop buttons - Custom audio scrubber with progress and thumb - Styled mix selector dropdown - 'create custom mix' link - Close button - Updated error and state banners Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
140c1b1783
commit
19b95fcce0
|
|
@ -0,0 +1,332 @@
|
|||
.jamtrack-player-window {
|
||||
background: #f5f5f7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
width: 420px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
/* Window Chrome */
|
||||
.jamtrack-player-chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f5f5f7 100%);
|
||||
border-bottom: 1px solid #d1d1d6;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.jamtrack-player-traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.traffic-light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.traffic-light.red {
|
||||
background: #ff5f56;
|
||||
}
|
||||
|
||||
.traffic-light.yellow {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
.traffic-light.green {
|
||||
background: #27c93f;
|
||||
}
|
||||
|
||||
.jamtrack-player-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.jamtrack-player-content {
|
||||
padding: 20px 24px;
|
||||
background: #ffffff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Playback Controls */
|
||||
.jamtrack-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.jamtrack-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jamtrack-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jamtrack-btn-play {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.jamtrack-btn-play:hover:not(:disabled) {
|
||||
background: #0051d5;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.jamtrack-btn-stop {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.jamtrack-btn-stop:hover:not(:disabled) {
|
||||
background: #0051d5;
|
||||
}
|
||||
|
||||
/* Time Display */
|
||||
.jamtrack-time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jamtrack-time {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
/* Scrubber */
|
||||
.jamtrack-scrubber {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: #e5e5ea;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jamtrack-scrubber:hover .jamtrack-scrubber-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.jamtrack-scrubber-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: #007aff;
|
||||
border-radius: 3px 0 0 3px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.jamtrack-scrubber-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #007aff;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.jamtrack-scrubber input[type="range"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Mix Selector */
|
||||
.jamtrack-mix-selector {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jamtrack-mix-label {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.jamtrack-mix-dropdown {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #1d1d1f;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jamtrack-mix-dropdown:hover {
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.jamtrack-mix-dropdown:focus {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
|
||||
.jamtrack-custom-mix-link {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.jamtrack-custom-mix-link a {
|
||||
font-size: 12px;
|
||||
color: #007aff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jamtrack-custom-mix-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.jamtrack-close-btn {
|
||||
margin-top: 20px;
|
||||
padding: 8px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #007aff;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.jamtrack-close-btn:hover {
|
||||
background: #f5f5f7;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
/* Download/Sync State Banner */
|
||||
.jamtrack-state-banner {
|
||||
background: #f0f8ff;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #cce7ff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.jamtrack-state-banner h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.jamtrack-state-banner p {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.jamtrack-state-progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.jamtrack-error-banner {
|
||||
background: #fff5f5;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #ffcccc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.jamtrack-error-banner.warning {
|
||||
background: #fffbf0;
|
||||
border-color: #ffe4a3;
|
||||
}
|
||||
|
||||
.jamtrack-error-banner strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c41e3a;
|
||||
}
|
||||
|
||||
.jamtrack-error-banner.warning strong {
|
||||
color: #a05a00;
|
||||
}
|
||||
|
||||
.jamtrack-error-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jamtrack-error-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.jamtrack-error-btn:hover {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.jamtrack-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e5e5ea;
|
||||
border-top-color: #007aff;
|
||||
border-radius: 50%;
|
||||
animation: jamtrack-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes jamtrack-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { setOpenJamTrack, clearOpenJamTrack } from '../../store/features/sessionUISlice';
|
||||
import { setAvailableMixdowns, setActiveMixdown } from '../../store/features/activeSessionSlice';
|
||||
import { useJamServerContext } from '../../context/JamServerContext';
|
||||
import './JKSessionJamTrackPlayer.css';
|
||||
|
||||
// Error types for comprehensive error handling
|
||||
const ERROR_TYPES = {
|
||||
|
|
@ -475,148 +476,190 @@ const JKSessionJamTrackPlayer = ({
|
|||
if (!isOpen && !isPopup) return null;
|
||||
|
||||
return (
|
||||
<div className="jamtrack-player">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: (error.type === ERROR_TYPES.FILE || error.type === ERROR_TYPES.NETWORK || error.type === ERROR_TYPES.DOWNLOAD) ? '#fee' : '#ffd',
|
||||
padding: '10px',
|
||||
marginBottom: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: (error.type === ERROR_TYPES.FILE || error.type === ERROR_TYPES.NETWORK || error.type === ERROR_TYPES.DOWNLOAD) ? '#fcc' : '#fc6'
|
||||
}}
|
||||
>
|
||||
<strong>{error.type.toUpperCase()} ERROR:</strong> {error.message}
|
||||
<div style={{ marginTop: '5px' }}>
|
||||
<button onClick={() => setError(null)}>Dismiss</button>
|
||||
{(error.type === ERROR_TYPES.NETWORK || error.type === ERROR_TYPES.DOWNLOAD || error.type === ERROR_TYPES.FILE) && (
|
||||
<button onClick={handleRetryError} style={{ marginLeft: '5px' }}>Retry</button>
|
||||
<div className="jamtrack-player-window">
|
||||
{/* Window Chrome */}
|
||||
<div className="jamtrack-player-chrome">
|
||||
<div className="jamtrack-player-traffic-lights">
|
||||
<div className="traffic-light red" onClick={onClose}></div>
|
||||
<div className="traffic-light yellow"></div>
|
||||
<div className="traffic-light green"></div>
|
||||
</div>
|
||||
<div className="jamtrack-player-title">
|
||||
JamTrack: {jamTrack?.name || 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="jamtrack-player-content">
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className={`jamtrack-error-banner ${(error.type === ERROR_TYPES.PLAYBACK || error.type === ERROR_TYPES.GENERAL) ? 'warning' : ''}`}>
|
||||
<strong>{error.type.toUpperCase()} ERROR:</strong>
|
||||
<div>{error.message}</div>
|
||||
<div className="jamtrack-error-actions">
|
||||
<button className="jamtrack-error-btn" onClick={() => setError(null)}>Dismiss</button>
|
||||
{(error.type === ERROR_TYPES.NETWORK || error.type === ERROR_TYPES.DOWNLOAD || error.type === ERROR_TYPES.FILE) && (
|
||||
<button className="jamtrack-error-btn" onClick={handleRetryError}>Retry</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download/Sync State Banner */}
|
||||
{downloadState.state !== 'idle' && downloadState.state !== 'synchronized' && (
|
||||
<div className="jamtrack-state-banner">
|
||||
<h4>
|
||||
{downloadState.state === 'checking' && 'Checking sync status...'}
|
||||
{downloadState.state === 'packaging' && 'Your JamTrack is currently being created in the JamKazam server'}
|
||||
{downloadState.state === 'downloading' && 'Downloading JamTrack...'}
|
||||
{downloadState.state === 'keying' && 'Requesting decryption keys...'}
|
||||
{downloadState.state === 'error' && 'Download Failed'}
|
||||
</h4>
|
||||
|
||||
{downloadState.state === 'packaging' && (
|
||||
<>
|
||||
<p>
|
||||
{downloadState.signing_state === 'SIGNED' && 'Package ready, starting download...'}
|
||||
{downloadState.signing_state !== 'SIGNED' && downloadState.signing_state && `Status: ${downloadState.signing_state}`}
|
||||
{!downloadState.signing_state && 'Preparing your JamTrack...'}
|
||||
</p>
|
||||
{downloadState.packaging_steps > 0 && (
|
||||
<p>Step {downloadState.current_packaging_step} of {downloadState.packaging_steps}</p>
|
||||
)}
|
||||
<div className="jamtrack-spinner" style={{ marginTop: '8px' }}></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'downloading' && (
|
||||
<>
|
||||
<progress
|
||||
className="jamtrack-state-progress"
|
||||
value={downloadState.progress}
|
||||
max="100"
|
||||
/>
|
||||
<p>{downloadState.progress}%</p>
|
||||
{downloadState.totalSteps > 0 && (
|
||||
<p>Step {downloadState.currentStep} of {downloadState.totalSteps}</p>
|
||||
)}
|
||||
<button className="jamtrack-error-btn" onClick={handleCancelDownload} style={{ marginTop: '8px' }}>Cancel</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'keying' && (
|
||||
<>
|
||||
<p>Finalizing download...</p>
|
||||
<div className="jamtrack-spinner" style={{ marginTop: '8px' }}></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'error' && (
|
||||
<>
|
||||
<p style={{ color: '#c41e3a' }}>{downloadState.error?.message || 'Download failed'}</p>
|
||||
<button className="jamtrack-error-btn" onClick={handleRetryDownload} style={{ marginTop: '8px' }}>Retry</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Download/Sync State Machine UI */}
|
||||
{downloadState.state !== 'idle' && downloadState.state !== 'synchronized' && (
|
||||
<div style={{ background: '#f0f8ff', padding: '15px', marginBottom: '10px', border: '1px solid #cce7ff' }}>
|
||||
<h4>
|
||||
{downloadState.state === 'checking' && 'Checking sync status...'}
|
||||
{downloadState.state === 'packaging' && 'Your JamTrack is currently being created in the JamKazam server'}
|
||||
{downloadState.state === 'downloading' && 'Downloading JamTrack...'}
|
||||
{downloadState.state === 'keying' && 'Requesting decryption keys...'}
|
||||
{downloadState.state === 'error' && 'Download Failed'}
|
||||
</h4>
|
||||
|
||||
{downloadState.state === 'packaging' && (
|
||||
<div>
|
||||
<p style={{ marginTop: '10px', fontSize: '14px' }}>
|
||||
{downloadState.signing_state === 'SIGNED' && 'Package ready, starting download...'}
|
||||
{downloadState.signing_state !== 'SIGNED' && downloadState.signing_state && `Status: ${downloadState.signing_state}`}
|
||||
{!downloadState.signing_state && 'Preparing your JamTrack...'}
|
||||
</p>
|
||||
{downloadState.packaging_steps > 0 && (
|
||||
<p style={{ fontSize: '14px', marginTop: '5px' }}>
|
||||
Step {downloadState.current_packaging_step} of {downloadState.packaging_steps}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<div className="spinner-border spinner-border-sm" role="status">
|
||||
<span className="sr-only">Packaging...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'downloading' && (
|
||||
<div>
|
||||
<progress
|
||||
value={downloadState.progress}
|
||||
max="100"
|
||||
style={{ width: '100%', height: '20px' }}
|
||||
/>
|
||||
<p>{downloadState.progress}%</p>
|
||||
{downloadState.totalSteps > 0 && (
|
||||
<p>Step {downloadState.currentStep} of {downloadState.totalSteps}</p>
|
||||
)}
|
||||
<button onClick={handleCancelDownload}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'keying' && (
|
||||
<div>
|
||||
<p>Finalizing download...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadState.state === 'error' && (
|
||||
<div style={{ color: '#d00' }}>
|
||||
<p>{downloadState.error?.message || 'Download failed'}</p>
|
||||
<button onClick={handleRetryDownload}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button onClick={handlePlay} disabled={isOperating || isLoadingSync}>
|
||||
{jamTrackState.isPaused ? 'Resume' : 'Play'}
|
||||
</button>
|
||||
<button onClick={handlePause} disabled={isOperating || !jamTrackState.isPlaying}>
|
||||
Pause
|
||||
</button>
|
||||
<button onClick={handleStop} disabled={isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={jamTrackState.durationMs || 100}
|
||||
value={jamTrackState.currentPositionMs || 0}
|
||||
onChange={(e) => handleSeek(parseInt(e.target.value, 10))}
|
||||
disabled={isOperating || !jamTrackState.durationMs}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{formattedPosition} / {formattedDuration}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{availableMixdowns.length > 0 && (
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<label>Mixdown:</label>
|
||||
<select
|
||||
value={selectedMixdownId || ''}
|
||||
onChange={(e) => handleMixdownChange(parseInt(e.target.value, 10))}
|
||||
{/* Playback Controls */}
|
||||
<div className="jamtrack-controls">
|
||||
<button
|
||||
className="jamtrack-btn jamtrack-btn-play"
|
||||
onClick={handlePlay}
|
||||
disabled={isOperating || isLoadingSync}
|
||||
title={jamTrackState.isPaused ? 'Resume' : 'Play'}
|
||||
>
|
||||
{availableMixdowns
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
// Sort order: master first, then custom mixes, then stems
|
||||
if (a.type === 'master') return -1;
|
||||
if (b.type === 'master') return 1;
|
||||
if (a.type === 'custom' || a.type === 'custom-mix') return -1;
|
||||
if (b.type === 'custom' || b.type === 'custom-mix') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map(mixdown => (
|
||||
<option key={mixdown.id} value={mixdown.id}>
|
||||
{mixdown.type === 'master' && '🎵 '}
|
||||
{(mixdown.type === 'custom' || mixdown.type === 'custom-mix') && '🎨 '}
|
||||
{mixdown.type === 'stem' && '🎸 '}
|
||||
{mixdown.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
{jamTrackState.isPlaying && !jamTrackState.isPaused ? (
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="4" height="14" />
|
||||
<rect x="8" y="0" width="4" height="14" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor">
|
||||
<path d="M0 0L12 7L0 14V0Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="jamtrack-btn jamtrack-btn-stop"
|
||||
onClick={handleStop}
|
||||
disabled={isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)}
|
||||
title="Stop"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<rect x="0" y="0" width="12" height="12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Time Display and Scrubber */}
|
||||
<div className="jamtrack-time-display">
|
||||
<span className="jamtrack-time">{formattedPosition}</span>
|
||||
<div className="jamtrack-scrubber">
|
||||
<div
|
||||
className="jamtrack-scrubber-progress"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div
|
||||
className="jamtrack-scrubber-thumb"
|
||||
style={{ left: 'calc(100% - 6px)' }}
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={jamTrackState.durationMs || 100}
|
||||
value={jamTrackState.currentPositionMs || 0}
|
||||
onChange={(e) => handleSeek(parseInt(e.target.value, 10))}
|
||||
disabled={isOperating || !jamTrackState.durationMs}
|
||||
/>
|
||||
</div>
|
||||
<span className="jamtrack-time">{formattedDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mix Selector */}
|
||||
{availableMixdowns.length > 0 && (
|
||||
<>
|
||||
<div className="jamtrack-mix-selector">
|
||||
<span className="jamtrack-mix-label">Mix:</span>
|
||||
<select
|
||||
className="jamtrack-mix-dropdown"
|
||||
value={selectedMixdownId || ''}
|
||||
onChange={(e) => handleMixdownChange(parseInt(e.target.value, 10))}
|
||||
disabled={isOperating || isLoadingSync}
|
||||
>
|
||||
{availableMixdowns
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
// Sort order: master first, then custom mixes, then stems
|
||||
if (a.type === 'master') return -1;
|
||||
if (b.type === 'master') return 1;
|
||||
if (a.type === 'custom' || a.type === 'custom-mix') return -1;
|
||||
if (b.type === 'custom' || b.type === 'custom-mix') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map(mixdown => (
|
||||
<option key={mixdown.id} value={mixdown.id}>
|
||||
{mixdown.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="jamtrack-custom-mix-link">
|
||||
<a onClick={() => console.log('TODO: Open custom mix creator')}>
|
||||
create custom mix
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
<button className="jamtrack-close-btn" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue