feat(26-02): cleanup callbacks on unmount and defer controls rendering

- Import and call cleanupJamTrackCallbacks in cleanup useEffect
- Wrap controls section in condition: only render when downloadState
  is 'idle' or 'synchronized' and not in initial loading
- Fixes memory leak from orphaned window.jamTrackDownload* globals
- Fixes UX issue where controls appeared before track ready
This commit is contained in:
Nuwan 2026-02-25 19:13:42 +05:30
parent 90d3fd29dd
commit dbaaba82f9
1 changed files with 143 additions and 137 deletions

View File

@ -5,7 +5,8 @@ import {
checkJamTrackSync,
closeJamTrack,
setJamTrackState,
setDownloadState
setDownloadState,
cleanupJamTrackCallbacks
} from '../../store/features/mediaSlice';
import { setOpenJamTrack, clearOpenJamTrack } from '../../store/features/sessionUISlice';
import { setAvailableMixdowns, setActiveMixdown } from '../../store/features/activeSessionSlice';
@ -168,6 +169,9 @@ const JKSessionJamTrackPlayer = ({
dispatch(closeJamTrack({ fqId: fqIdRef.current, jamClient }));
dispatch(clearOpenJamTrack());
}
// Clean up download callbacks to prevent memory leaks
cleanupJamTrackCallbacks();
};
}, [dispatch, jamClient]);
@ -717,152 +721,154 @@ const JKSessionJamTrackPlayer = ({
</div>
)}
{/* Controls Section */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Circular Buttons and Seek Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* Play Button - Circular */}
<button
onClick={handlePlay}
disabled={isOperating || isLoadingSync}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
backgroundColor: (jamTrackState.isPlaying && !jamTrackState.isPaused) ? '#6c757d' : '#5b9bd5',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (isOperating || isLoadingSync) ? 'not-allowed' : 'pointer',
opacity: (isOperating || isLoadingSync) ? 0.5 : 1,
outline: 'none',
flexShrink: 0
}}
>
{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>
{/* Stop Button - Circular */}
<button
onClick={handleStop}
disabled={isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
backgroundColor: '#b0b0b0',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)) ? 'not-allowed' : 'pointer',
opacity: (isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)) ? 0.5 : 1,
outline: 'none',
flexShrink: 0
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="0" y="0" width="12" height="12" />
</svg>
</button>
{/* Time and Seek Bar */}
<div style={{ display: 'flex', alignItems: 'center', flex: 1, gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666', minWidth: '35px', fontFamily: 'monospace' }}>
{formattedPosition}
</span>
<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}
{/* Controls Section - only show when track is ready (not in loading/download states) */}
{(downloadState.state === 'idle' || downloadState.state === 'synchronized') && !isLoadingSync && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Circular Buttons and Seek Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* Play Button - Circular */}
<button
onClick={handlePlay}
disabled={isOperating || isLoadingSync}
style={{
flex: 1,
height: '4px',
borderRadius: '2px',
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
backgroundColor: (jamTrackState.isPlaying && !jamTrackState.isPaused) ? '#6c757d' : '#5b9bd5',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (isOperating || isLoadingSync) ? 'not-allowed' : 'pointer',
opacity: (isOperating || isLoadingSync) ? 0.5 : 1,
outline: 'none',
background: `linear-gradient(to right, #5b9bd5 0%, #5b9bd5 ${progressPercent}%, #ddd ${progressPercent}%, #ddd 100%)`,
WebkitAppearance: 'none',
cursor: (isOperating || !jamTrackState.durationMs) ? 'not-allowed' : 'pointer'
flexShrink: 0
}}
/>
<span style={{ fontSize: '14px', color: '#666', minWidth: '35px', fontFamily: 'monospace' }}>
{formattedDuration}
</span>
</div>
</div>
>
{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>
{/* Mix Selector */}
{availableMixdowns.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', color: '#6e6e73', fontWeight: '500' }}>
Mix:
{/* Stop Button - Circular */}
<button
onClick={handleStop}
disabled={isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
backgroundColor: '#b0b0b0',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)) ? 'not-allowed' : 'pointer',
opacity: (isOperating || (!jamTrackState.isPlaying && !jamTrackState.isPaused)) ? 0.5 : 1,
outline: 'none',
flexShrink: 0
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="0" y="0" width="12" height="12" />
</svg>
</button>
{/* Time and Seek Bar */}
<div style={{ display: 'flex', alignItems: 'center', flex: 1, gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666', minWidth: '35px', fontFamily: 'monospace' }}>
{formattedPosition}
</span>
<select
value={selectedMixdownId || ''}
onChange={(e) => handleMixdownChange(parseInt(e.target.value, 10))}
disabled={isOperating || isLoadingSync}
<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={{
flex: 1,
padding: '6px 12px',
fontSize: '13px',
border: '1px solid #d1d1d6',
borderRadius: '6px',
background: 'white',
color: '#1d1d1f',
cursor: 'pointer',
outline: 'none'
height: '4px',
borderRadius: '2px',
outline: 'none',
background: `linear-gradient(to right, #5b9bd5 0%, #5b9bd5 ${progressPercent}%, #ddd ${progressPercent}%, #ddd 100%)`,
WebkitAppearance: 'none',
cursor: (isOperating || !jamTrackState.durationMs) ? 'not-allowed' : 'pointer'
}}
>
{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 style={{ textAlign: 'right' }}>
<a
onClick={() => window.open(`/jamtracks/${jamTrack?.id}`, '_blank', 'noopener,noreferrer')}
style={{
fontSize: '12px',
color: '#007aff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
create custom mix
</a>
/>
<span style={{ fontSize: '14px', color: '#666', minWidth: '35px', fontFamily: 'monospace' }}>
{formattedDuration}
</span>
</div>
</div>
)}
</div>
{/* Mix Selector */}
{availableMixdowns.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', color: '#6e6e73', fontWeight: '500' }}>
Mix:
</span>
<select
value={selectedMixdownId || ''}
onChange={(e) => handleMixdownChange(parseInt(e.target.value, 10))}
disabled={isOperating || isLoadingSync}
style={{
flex: 1,
padding: '6px 12px',
fontSize: '13px',
border: '1px solid #d1d1d6',
borderRadius: '6px',
background: 'white',
color: '#1d1d1f',
cursor: 'pointer',
outline: 'none'
}}
>
{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 style={{ textAlign: 'right' }}>
<a
onClick={() => window.open(`/jamtracks/${jamTrack?.id}`, '_blank', 'noopener,noreferrer')}
style={{
fontSize: '12px',
color: '#007aff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
create custom mix
</a>
</div>
</div>
)}
</div>
)}
{/* Close Button */}
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '8px' }}>