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:
Nuwan 2026-01-15 20:39:24 +05:30
parent 140c1b1783
commit 19b95fcce0
2 changed files with 511 additions and 136 deletions

View File

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

View File

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