docs(05): create phase plan

Phase 5: JamTrack Implementation
- 5 plans created
- 14 total tasks defined
- Ready for execution

Plan breakdown:
- 05-01: Redux Infrastructure & Bug Fixes (2 tasks)
- 05-02: Async Thunks & Component Core (3 tasks)
- 05-03: Playback Controls & Polling (3 tasks)
- 05-04: Mixdown Selection & Download UI (3 tasks)
- 05-05: Error Handling & Final UAT (3 tasks, includes UAT checkpoint)

Scope: Download + play JamTracks + select existing mixdowns
Quality bar: Match Backing Track player feel and reliability
Estimated complexity: 2.5-3x Phase 3 effort

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-14 23:48:22 +05:30
parent 81feb2666e
commit cc32abcaba
5 changed files with 1945 additions and 0 deletions

View File

@ -0,0 +1,219 @@
---
phase: 05-jamtrack-implementation
plan: 01
type: execute
---
<objective>
Establish Redux infrastructure for JamTrack with critical bug fix and state structure extensions.
Purpose: Fix existing fqId bug that blocks all JamTrack functionality, then add Redux state structure needed for download/sync machine, mixdown selection, and UI preferences.
Output: Redux slices extended with JamTrack state, bug fixed, ready for async thunks.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-jamtrack-implementation/05-CONTEXT.md
@.planning/phases/04-jamtrack-research-design/04-02-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/REDUX_DESIGN.md
@.planning/codebase/CONVENTIONS.md
@.planning/codebase/JAMTRACK_API.md
@jam-ui/src/store/features/mediaSlice.js
@jam-ui/src/store/features/sessionUISlice.js
**Tech stack available:**
- React 16.13.1
- Redux Toolkit 1.6.1
- jamClient (C++ native client proxy via QWebChannel)
- WebSocket Protocol Buffer messaging
**Established patterns from Phase 3 & 4:**
- jamClient passed as prop (non-serializable, never in Redux)
- jamClient returns Promises (always async/await)
- jamClient returns strings for position/duration (always parseInt)
- fqId format mandatory: `{jamTrackId}-{sampleRate}` for all JamTrack calls
- Cleanup on unmount required (prevent stale state)
**Constraining decisions:**
- Phase 4: Redux state split: jamTrackState (playback), downloadState (sync machine), availableMixdowns (mixdown cache)
- Phase 4: 6 async thunks needed: loadJamTrack (enhanced), downloadJamTrack, checkJamTrackSync, loadJMEP, seekJamTrack, closeJamTrack
- Phase 4: Enhanced loadJamTrack fixes existing bug: uses fqId format instead of jamTrack.id
- Phase 5 Context: All three pillars equally important (download UX, playback reliability, mixdown selection)
- Phase 5 Context: Match Backing Track player feel and quality
**Critical bug to fix:**
- mediaSlice.js line 29: `JamTrackPlay(jamTrack.id)` should be `JamTrackPlay(fqId)`
- This bug blocks ALL JamTrack playback - must fix in Task 1
</context>
<tasks>
<task type="auto">
<name>Task 1: Fix loadJamTrack fqId bug and extend mediaSlice</name>
<files>jam-ui/src/store/features/mediaSlice.js</files>
<action>
**CRITICAL BUG FIX (line 29):**
Change `await jamClient.JamTrackPlay(jamTrack.id)` to `await jamClient.JamTrackPlay(fqId)` where fqId is already built on line 24.
**Then extend mediaSlice initialState:**
1. Replace `jamTrackState: {}` with full structure:
```javascript
jamTrackState: {
isPlaying: false,
isPaused: false,
currentPositionMs: 0,
durationMs: 0,
selectedMixdownId: null,
playbackMode: null, // 'master' | 'custom-mix' | 'stem'
lastUpdate: null
}
```
2. Replace `downloadingJamTrack: false` with full downloadState:
```javascript
downloadState: {
jamTrackId: null,
mixdownId: null,
fqId: null,
state: 'idle', // 'idle' | 'checking' | 'downloading' | 'keying' | 'synchronized' | 'error'
progress: 0,
currentStep: 0,
totalSteps: 0,
error: null
}
```
**Add new reducers:**
- setJamTrackState(state, action) - Sets full jamTrackState from action.payload
- setDownloadState(state, action) - Sets full downloadState from action.payload
- clearDownloadState(state) - Resets downloadState to initial values
**Keep existing:**
- updateJamTrackState (for partial updates from WebSocket)
- clearJamTrackState (for cleanup)
**Do NOT add thunks yet** - that's Plan 2. Only fix bug and add state structure.
</action>
<verify>
1. grep "JamTrackPlay(fqId)" jam-ui/src/store/features/mediaSlice.js (not jamTrack.id)
2. grep "state: 'idle'" jam-ui/src/store/features/mediaSlice.js (downloadState exists)
3. grep "setDownloadState" jam-ui/src/store/features/mediaSlice.js (new reducer exists)
</verify>
<done>
- loadJamTrack bug fixed (uses fqId not jamTrack.id)
- jamTrackState extended with 7 fields (isPlaying, isPaused, currentPositionMs, durationMs, selectedMixdownId, playbackMode, lastUpdate)
- downloadState added with 8 fields (jamTrackId, mixdownId, fqId, state, progress, currentStep, totalSteps, error)
- 3 new reducers added (setJamTrackState, setDownloadState, clearDownloadState)
- No TypeScript/linter errors introduced
</done>
</task>
<task type="auto">
<name>Task 2: Extend activeSessionSlice and sessionUISlice</name>
<files>jam-ui/src/store/features/activeSessionSlice.js, jam-ui/src/store/features/sessionUISlice.js</files>
<action>
**In activeSessionSlice.js:**
Add to initialState:
```javascript
availableMixdowns: [], // Array of mixdown objects: { id, type: 'master'|'custom-mix'|'stem', name, jamTrackId, packageId }
activeMixdown: null, // Currently selected mixdown object
mixdownCache: {} // Map of packageId -> { metadata, sampleRate, fileType, encryptType }
```
Add reducers:
- setAvailableMixdowns(state, action) - Sets availableMixdowns array
- setActiveMixdown(state, action) - Sets activeMixdown object
- cacheMixdownPackage(state, action) - Adds/updates entry in mixdownCache: `state.mixdownCache[action.payload.packageId] = action.payload.metadata`
- clearMixdowns(state) - Resets all three to initial values
**In sessionUISlice.js:**
Add to initialState:
```javascript
openJamTrack: null, // Currently open JamTrack ID or null
jamTrackUI: {
lastUsedMixdownId: null, // User's last selected mixdown for this session
volume: 100 // Last used volume (0-100)
}
```
Add reducers:
- setOpenJamTrack(state, action) - Sets openJamTrack ID
- updateJamTrackUI(state, action) - Merges action.payload into jamTrackUI: `state.jamTrackUI = { ...state.jamTrackUI, ...action.payload }`
- clearOpenJamTrack(state) - Sets openJamTrack to null
**Do NOT add thunks** - only state structure and reducers.
</action>
<verify>
1. grep "availableMixdowns" jam-ui/src/store/features/activeSessionSlice.js (exists in state and reducer)
2. grep "openJamTrack" jam-ui/src/store/features/sessionUISlice.js (exists in state and reducer)
3. grep "setActiveMixdown" jam-ui/src/store/features/activeSessionSlice.js (reducer exists)
</verify>
<done>
- activeSessionSlice extended with availableMixdowns, activeMixdown, mixdownCache state + 4 reducers
- sessionUISlice extended with openJamTrack, jamTrackUI state + 3 reducers
- All reducers follow Redux Toolkit patterns (immutable updates)
- No TypeScript/linter errors introduced
</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] Bug fix verified: loadJamTrack uses fqId not jamTrack.id
- [ ] All state structures added with correct field names and types
- [ ] All reducers added and follow Redux Toolkit patterns
- [ ] No errors when importing slices
</verification>
<success_criteria>
- loadJamTrack fqId bug fixed (critical blocker removed)
- mediaSlice extended: jamTrackState (7 fields), downloadState (8 fields), 3 new reducers
- activeSessionSlice extended: availableMixdowns, activeMixdown, mixdownCache, 4 new reducers
- sessionUISlice extended: openJamTrack, jamTrackUI, 3 new reducers
- All code follows existing patterns and conventions
- Redux state foundation ready for async thunks (Plan 2)
</success_criteria>
<output>
After completion, create `.planning/phases/05-jamtrack-implementation/05-01-SUMMARY.md`:
# Phase 5 Plan 1: Redux Infrastructure & Bug Fixes Summary
**[Substantive one-liner - what shipped]**
## Accomplishments
- Fixed critical loadJamTrack fqId bug
- Extended mediaSlice with jamTrackState and downloadState
- Extended activeSessionSlice with mixdown management state
- Extended sessionUISlice with JamTrack UI preferences
## Files Created/Modified
- `jam-ui/src/store/features/mediaSlice.js` - Bug fix + state extensions
- `jam-ui/src/store/features/activeSessionSlice.js` - Mixdown state
- `jam-ui/src/store/features/sessionUISlice.js` - UI preferences
## Decisions Made
[Key decisions and rationale]
## Issues Encountered
[Problems and resolutions, or "None"]
## Next Step
Ready for Plan 2: Async Thunks & Component Core
</output>

View File

@ -0,0 +1,506 @@
---
phase: 05-jamtrack-implementation
plan: 02
type: execute
---
<objective>
Implement 6 async thunks for JamTrack operations, add 3 WebSocket handlers, and create component skeleton.
Purpose: Build the async operation layer (download, sync, load, seek, close) with WebSocket real-time updates, then create the JKSessionJamTrackPlayer component shell with initialization/cleanup patterns.
Output: Complete async infrastructure + skeleton component ready for playback controls.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-jamtrack-implementation/05-CONTEXT.md
@.planning/phases/05-jamtrack-implementation/05-01-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/04-02-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/REDUX_DESIGN.md
@.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md
@.planning/codebase/CONVENTIONS.md
@.planning/codebase/JAMTRACK_API.md
@.planning/codebase/JAMTRACK_REACT_PATTERNS.md
@jam-ui/src/store/features/mediaSlice.js
@jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
**Established patterns:**
- jamClient methods return Promises (always async/await)
- jamClient returns strings for position/duration (always parseInt before math)
- fqId format: `{jamTrackId}-${sampleRate === 48 ? '48' : '44'}`
- Thunks use rejectWithValue for error handling
- WebSocket handlers dispatch Redux actions
- Component initialization checks sync state before playing
- Cleanup on unmount prevents stale state
**From Phase 4 design:**
- 6 async thunks: loadJamTrack (enhanced), downloadJamTrack, checkJamTrackSync, loadJMEP, seekJamTrack, closeJamTrack
- 3 WebSocket handlers: MIXER_CHANGES (extend), JAM_TRACK_CHANGES (new), MIXDOWN_CHANGES (new)
- JKSessionJamTrackPlayer props: isOpen, onClose, isPopup, jamTrack, jamClient, session, currentUser, initialMixdownId, autoPlay
- Initialization pattern: build fqId → check sync → download if needed → load JMEP → play
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement 6 async thunks in mediaSlice</name>
<files>jam-ui/src/store/features/mediaSlice.js</files>
<action>
Add 6 new thunks to mediaSlice.js (after existing thunks, before slice definition):
**1. Enhanced loadJamTrack (REPLACE existing):**
```javascript
export const loadJamTrack = createAsyncThunk(
'media/loadJamTrack',
async ({ jamTrack, mixdownId = null, autoPlay = false, jamClient }, { dispatch, rejectWithValue }) => {
try {
// Build fqId
const sampleRate = await jamClient.GetSampleRate();
const fqId = `${jamTrack.id}-${sampleRate === 48 ? '48' : '44'}`;
// Check sync state
const trackDetail = await jamClient.JamTrackGetTrackDetail(fqId);
// If not synchronized, trigger download
if (!trackDetail || !trackDetail.key_state || trackDetail.key_state !== 'AVAILABLE') {
await dispatch(downloadJamTrack({ jamTrack, mixdownId, fqId, jamClient })).unwrap();
}
// Load JMEP if present
if (jamTrack.jmep) {
await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
}
// Play with fqId (not jamTrack.id)
const result = await jamClient.JamTrackPlay(fqId);
if (!result) {
throw new Error('Unable to play JamTrack');
}
return { jamTrack, fqId, result };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
```
**2. downloadJamTrack:**
```javascript
export const downloadJamTrack = createAsyncThunk(
'media/downloadJamTrack',
async ({ jamTrack, mixdownId, fqId, jamClient }, { dispatch, rejectWithValue }) => {
try {
dispatch(setDownloadState({
jamTrackId: jamTrack.id,
mixdownId,
fqId,
state: 'downloading',
progress: 0
}));
// Get package ID (simplified - use first available package)
// Real implementation would use pickMyPackage() logic from legacy
const mixdowns = await jamClient.JamTrackGetMixdowns(jamTrack.id);
const packageId = mixdowns && mixdowns.length > 0 ? mixdowns[0].packageId : null;
if (!packageId) {
throw new Error('No mixdown package available');
}
// Set up global callbacks for native client
window.jamTrackDownloadProgress = (percent) => {
dispatch(setDownloadState({ progress: percent }));
};
window.jamTrackDownloadSuccess = () => {
dispatch(setDownloadState({ state: 'keying' }));
};
window.jamTrackDownloadFail = (error) => {
dispatch(setDownloadState({ state: 'error', error: { type: 'download', message: error } }));
};
// Initiate download (callbacks as string names)
await jamClient.JamTrackDownload(
jamTrack.id,
packageId,
jamTrack.userId,
'jamTrackDownloadProgress',
'jamTrackDownloadSuccess',
'jamTrackDownloadFail'
);
// Request keys
await jamClient.JamTrackKeysRequest();
dispatch(setDownloadState({ state: 'synchronized' }));
return { jamTrackId: jamTrack.id, fqId };
} catch (error) {
dispatch(setDownloadState({ state: 'error', error: { type: 'download', message: error.message } }));
return rejectWithValue(error.message || error);
}
}
);
```
**3. checkJamTrackSync:**
```javascript
export const checkJamTrackSync = createAsyncThunk(
'media/checkJamTrackSync',
async ({ jamTrack, jamClient }, { rejectWithValue }) => {
try {
const sampleRate = await jamClient.GetSampleRate();
const fqId = `${jamTrack.id}-${sampleRate === 48 ? '48' : '44'}`;
const trackDetail = await jamClient.JamTrackGetTrackDetail(fqId);
return { fqId, trackDetail, isSynchronized: trackDetail && trackDetail.key_state === 'AVAILABLE' };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
```
**4. loadJMEP:**
```javascript
export const loadJMEP = createAsyncThunk(
'media/loadJMEP',
async ({ jamTrack, fqId, jamClient }, { rejectWithValue }) => {
try {
if (!jamTrack.jmep) {
return { loaded: false };
}
await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
return { loaded: true, fqId };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
```
**5. seekJamTrack:**
```javascript
export const seekJamTrack = createAsyncThunk(
'media/seekJamTrack',
async ({ fqId, positionMs, jamClient }, { getState, rejectWithValue }) => {
try {
// Apply UAT-003 fix pattern: if paused, store pending seek
const { media } = getState();
if (media.jamTrackState.isPaused) {
// Store pending seek, will be applied on resume
return { pendingSeek: positionMs };
}
await jamClient.JamTrackSeekMs(fqId, positionMs);
return { positionMs };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
```
**6. closeJamTrack:**
```javascript
export const closeJamTrack = createAsyncThunk(
'media/closeJamTrack',
async ({ fqId, jamClient }, { dispatch, rejectWithValue }) => {
try {
// Stop playback if playing
const positionStr = await jamClient.JamTrackGetPositionMs(fqId);
const position = parseInt(positionStr, 10);
if (position > 0) {
await jamClient.JamTrackStop(fqId);
}
// Clear Redux state
dispatch(clearJamTrackState());
dispatch(clearDownloadState());
return { closed: true };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
```
**In extraReducers, add handlers for these thunks** (pending/fulfilled/rejected for loading states).
**Import setDownloadState, clearJamTrackState, clearDownloadState** from reducers section.
</action>
<verify>
1. grep "export const downloadJamTrack" jam-ui/src/store/features/mediaSlice.js (thunk exists)
2. grep "export const seekJamTrack" jam-ui/src/store/features/mediaSlice.js (thunk exists)
3. grep "jamTrackDownloadProgress" jam-ui/src/store/features/mediaSlice.js (callback setup exists)
4. Count thunks: should have loadJamTrack + downloadJamTrack + checkJamTrackSync + loadJMEP + seekJamTrack + closeJamTrack = 6 new thunks
</verify>
<done>
- 6 async thunks implemented with correct signatures
- All thunks use fqId format (not jamTrack.id)
- downloadJamTrack sets up global callbacks for native client
- seekJamTrack applies UAT-003 fix pattern (pending seek while paused)
- closeJamTrack cleans up state properly
- extraReducers handle loading states for all thunks
- No TypeScript/linter errors
</done>
</task>
<task type="auto">
<name>Task 2: Add 3 WebSocket handlers</name>
<files>jam-ui/src/hooks/useWebSocketSubscription.js (or equivalent WebSocket handler file)</files>
<action>
Find the WebSocket handler file where MIXER_CHANGES and other message handlers are registered. Look for files in:
- jam-ui/src/hooks/useWebSocketSubscription.js
- jam-ui/src/hooks/useWebSocket.js
- jam-ui/src/contexts/WebSocketContext.js
- jam-ui/src/store/websocketMiddleware.js
**Once found, add/extend 3 handlers:**
**1. Extend MIXER_CHANGES handler:**
```javascript
case 'MIXER_CHANGES':
// Existing backing track mixer logic...
// NEW: Handle JamTrack mixdown changes
if (message.jamTrackMixdowns) {
dispatch(setAvailableMixdowns(message.jamTrackMixdowns));
}
break;
```
**2. Add JAM_TRACK_CHANGES handler (new):**
```javascript
case 'JAM_TRACK_CHANGES':
dispatch(updateJamTrackState({
isPlaying: message.jamTrackState?.isPlaying || false,
isPaused: message.jamTrackState?.isPaused || false,
currentPositionMs: parseInt(message.jamTrackState?.currentPositionMs || '0', 10),
lastUpdate: Date.now()
}));
break;
```
**3. Add MIXDOWN_CHANGES handler (new):**
```javascript
case 'MIXDOWN_CHANGES':
if (message.mixdownPackage) {
dispatch(setDownloadState({
currentStep: message.mixdownPackage.current_packaging_step || 0,
totalSteps: message.mixdownPackage.packaging_steps || 0
}));
}
break;
```
**Import actions at top:**
```javascript
import { updateJamTrackState, setDownloadState } from '../store/features/mediaSlice';
import { setAvailableMixdowns } from '../store/features/activeSessionSlice';
```
**If WebSocket file not found,** document in commit message that handlers need to be added when WebSocket integration point is identified.
</action>
<verify>
1. grep "JAM_TRACK_CHANGES" (handler added)
2. grep "MIXDOWN_CHANGES" (handler added)
3. grep "updateJamTrackState" (dispatch call exists)
4. grep "import.*updateJamTrackState" (import added)
</verify>
<done>
- 3 WebSocket handlers added/extended (MIXER_CHANGES, JAM_TRACK_CHANGES, MIXDOWN_CHANGES)
- Handlers dispatch correct Redux actions
- All imports added
- Handlers parse message data correctly (parseInt for numbers, handle nulls)
</done>
</task>
<task type="auto">
<name>Task 3: Create JKSessionJamTrackPlayer component skeleton</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Create new file following JKSessionBackingTrackPlayer.js patterns:
```javascript
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
loadJamTrack,
checkJamTrackSync,
closeJamTrack,
setJamTrackState
} from '../../store/features/mediaSlice';
import { setOpenJamTrack, clearOpenJamTrack } from '../../store/features/sessionUISlice';
const JKSessionJamTrackPlayer = ({
isOpen,
onClose,
isPopup = false,
jamTrack,
jamClient,
session,
currentUser,
initialMixdownId = null,
autoPlay = false
}) => {
// Local state
const [error, setError] = useState(null);
const [isLoadingSync, setIsLoadingSync] = useState(false);
const [isOperating, setIsOperating] = useState(false);
const [selectedMixdownId, setSelectedMixdownId] = useState(initialMixdownId);
// Redux state
const dispatch = useDispatch();
const jamTrackState = useSelector(state => state.media.jamTrackState);
const downloadState = useSelector(state => state.media.downloadState);
const availableMixdowns = useSelector(state => state.activeSession.availableMixdowns);
// Refs
const fqIdRef = useRef(null);
const mountedRef = useRef(true);
// Helper: Build fqId
const buildFqId = useCallback(async () => {
if (!jamClient || !jamTrack) return null;
const sampleRate = await jamClient.GetSampleRate();
return `${jamTrack.id}-${sampleRate === 48 ? '48' : '44'}`;
}, [jamClient, jamTrack]);
// Initialization
useEffect(() => {
if (!isOpen && !isPopup) return;
if (!jamClient || !jamTrack) return;
const initializePlayer = async () => {
try {
setIsLoadingSync(true);
setError(null);
// Build fqId
const fqId = await buildFqId();
fqIdRef.current = fqId;
// Check sync state
const syncResult = await dispatch(checkJamTrackSync({ jamTrack, jamClient })).unwrap();
if (!syncResult.isSynchronized) {
// Download flow will be triggered by loadJamTrack
console.log('[JamTrack] Not synchronized, will download');
}
// Set open in Redux
dispatch(setOpenJamTrack(jamTrack.id));
// Load and play if autoPlay
if (autoPlay) {
await dispatch(loadJamTrack({
jamTrack,
mixdownId: selectedMixdownId,
autoPlay: true,
jamClient
})).unwrap();
}
} catch (err) {
console.error('[JamTrack] Initialization error:', err);
setError({ type: 'initialization', message: err.message || 'Failed to initialize JamTrack' });
} finally {
if (mountedRef.current) {
setIsLoadingSync(false);
}
}
};
initializePlayer();
}, [isOpen, isPopup, jamTrack, jamClient, autoPlay, selectedMixdownId, dispatch, buildFqId]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
if (fqIdRef.current && jamClient) {
dispatch(closeJamTrack({ fqId: fqIdRef.current, jamClient }));
dispatch(clearOpenJamTrack());
}
};
}, [dispatch, jamClient]);
// Placeholder render (will be filled in Plan 3)
if (!isOpen && !isPopup) return null;
return (
<div className="jamtrack-player">
<h3>JamTrack Player (Skeleton)</h3>
{isLoadingSync && <p>Checking sync status...</p>}
{error && <p style={{ color: 'red' }}>{error.message}</p>}
{downloadState.state !== 'idle' && (
<p>Download State: {downloadState.state} ({downloadState.progress}%)</p>
)}
<p>Ready for playback controls (Plan 3)</p>
</div>
);
};
export default JKSessionJamTrackPlayer;
```
**Follow conventions:**
- JK prefix (matches JKSessionBackingTrackPlayer)
- Located in jam-ui/src/components/client/
- Uses Redux Toolkit hooks (useDispatch, useSelector)
- Props interface matches Phase 4 design
- Initialization pattern: build fqId → check sync → load if autoPlay
- Cleanup on unmount
- mountedRef prevents state updates after unmount
</action>
<verify>
1. ls jam-ui/src/components/client/JKSessionJamTrackPlayer.js (file exists)
2. grep "const JKSessionJamTrackPlayer" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (component defined)
3. grep "buildFqId" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (helper exists)
4. grep "closeJamTrack" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (cleanup logic)
</verify>
<done>
- JKSessionJamTrackPlayer.js created with correct structure
- Props interface matches Phase 4 design (11 props)
- Initialization pattern implemented (build fqId → check sync → autoPlay if requested)
- Cleanup on unmount prevents stale state
- Local state for UI (error, isLoadingSync, isOperating, selectedMixdownId)
- Redux state connected (jamTrackState, downloadState, availableMixdowns)
- Placeholder render (will be enhanced in Plan 3)
- No TypeScript/linter errors
</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] All 6 async thunks implemented and exported
- [ ] All thunks use fqId format correctly
- [ ] 3 WebSocket handlers added/extended
- [ ] JKSessionJamTrackPlayer component created with initialization/cleanup
- [ ] No errors when importing component or slices
</verification>
<success_criteria>
- 6 async thunks complete: loadJamTrack (enhanced), downloadJamTrack, checkJamTrackSync, loadJMEP, seekJamTrack, closeJamTrack
- 3 WebSocket handlers: MIXER_CHANGES (extended), JAM_TRACK_CHANGES (new), MIXDOWN_CHANGES (new)
- JKSessionJamTrackPlayer component skeleton created with props interface, initialization pattern, cleanup logic
- All code follows Backing Track patterns and Phase 4 design
- Component ready for playback controls (Plan 3)
</success_criteria>
<output>
After completion, create `.planning/phases/05-jamtrack-implementation/05-02-SUMMARY.md`
</output>

View File

@ -0,0 +1,416 @@
---
phase: 05-jamtrack-implementation
plan: 03
type: execute
---
<objective>
Implement playback controls, visibility-aware polling, and seek slider for JamTrack player.
Purpose: Add play/pause/stop controls with jamClient integration, implement 500ms/2000ms polling pattern from Phase 3, and wire seek slider with UAT-003 fix pattern.
Output: Functional playback controls with real-time position updates and smooth seeking.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-jamtrack-implementation/05-CONTEXT.md
@.planning/phases/05-jamtrack-implementation/05-02-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md
@.planning/codebase/JAMTRACK_API.md
@jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
@jam-ui/src/components/client/JKSessionJamTrackPlayer.js
**Established patterns from Phase 3:**
- Visibility-aware polling: 500ms visible, 2000ms hidden
- useCallback for all handlers (prevent re-renders)
- Conditional state updates in polling (only update if values changed)
- UAT-003 fix: pending seek while paused, apply on resume
- End-of-track handling: stop and reset when position >= duration
- isOperating flag prevents rapid clicks during async operations
**From Phase 4 design:**
- Playback controls: handlePlay, handlePause, handleStop
- Polling: JamTrackGetPositionMs, JamTrackGetDurationMs
- Seek: handleSeek dispatches seekJamTrack thunk
- End-of-track: detect currentPositionMs >= durationMs
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement playback control handlers</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add playback control handlers to JKSessionJamTrackPlayer:
**1. handlePlay:**
```javascript
const handlePlay = useCallback(async () => {
if (isOperating || !jamClient || !fqIdRef.current) return;
try {
setIsOperating(true);
setError(null);
// If not playing yet, load JamTrack
if (!jamTrackState.isPlaying) {
await dispatch(loadJamTrack({
jamTrack,
mixdownId: selectedMixdownId,
autoPlay: true,
jamClient
})).unwrap();
} else if (jamTrackState.isPaused) {
// Resume from pause
await jamClient.JamTrackResume(fqIdRef.current);
// Apply pending seek if exists (UAT-003 fix)
if (pendingSeekRef.current !== null) {
await jamClient.JamTrackSeekMs(fqIdRef.current, pendingSeekRef.current);
pendingSeekRef.current = null;
}
dispatch(setJamTrackState({ isPaused: false, isPlaying: true }));
}
} catch (err) {
console.error('[JamTrack] Play error:', err);
setError({ type: 'playback', message: 'Failed to play JamTrack' });
} finally {
if (mountedRef.current) {
setIsOperating(false);
}
}
}, [isOperating, jamClient, jamTrack, jamTrackState, selectedMixdownId, dispatch]);
```
**2. handlePause:**
```javascript
const handlePause = useCallback(async () => {
if (isOperating || !jamClient || !fqIdRef.current) return;
try {
setIsOperating(true);
setError(null);
await jamClient.JamTrackPause(fqIdRef.current);
dispatch(setJamTrackState({ isPaused: true, isPlaying: false }));
} catch (err) {
console.error('[JamTrack] Pause error:', err);
setError({ type: 'playback', message: 'Failed to pause JamTrack' });
} finally {
if (mountedRef.current) {
setIsOperating(false);
}
}
}, [isOperating, jamClient, dispatch]);
```
**3. handleStop:**
```javascript
const handleStop = useCallback(async () => {
if (isOperating || !jamClient || !fqIdRef.current) return;
try {
setIsOperating(true);
setError(null);
await jamClient.JamTrackStop(fqIdRef.current);
dispatch(setJamTrackState({
isPlaying: false,
isPaused: false,
currentPositionMs: 0
}));
} catch (err) {
console.error('[JamTrack] Stop error:', err);
setError({ type: 'playback', message: 'Failed to stop JamTrack' });
} finally {
if (mountedRef.current) {
setIsOperating(false);
}
}
}, [isOperating, jamClient, dispatch]);
```
**Add refs:**
```javascript
const pendingSeekRef = useRef(null);
```
**Add buttons to render (replace placeholder):**
```javascript
return (
<div className="jamtrack-player">
{error && (
<div style={{ background: error.type === 'file' || error.type === 'network' ? '#fee' : '#ffd', padding: '10px' }}>
{error.message}
<button onClick={() => setError(null)}>Dismiss</button>
</div>
)}
{downloadState.state !== 'idle' && downloadState.state !== 'synchronized' && (
<div>
<p>Download: {downloadState.state} ({downloadState.progress}%)</p>
</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>
<p>Position: {jamTrackState.currentPositionMs}ms / Duration: {jamTrackState.durationMs}ms</p>
</div>
</div>
);
```
**Follow Phase 3 patterns:**
- isOperating flag prevents rapid clicks
- useCallback for all handlers
- try/catch with user-friendly error messages
- mountedRef check before setState
- Disabled buttons during operations
</action>
<verify>
1. grep "const handlePlay" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
2. grep "const handlePause" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
3. grep "pendingSeekRef" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (UAT-003 fix ref exists)
4. grep "disabled={isOperating" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (buttons disabled during operations)
</verify>
<done>
- 3 playback handlers implemented (handlePlay, handlePause, handleStop)
- All handlers use useCallback for performance
- UAT-003 fix pattern applied (pendingSeekRef for pause-seek-resume)
- Error handling with typed errors (file/network red, playback yellow)
- Buttons disabled correctly (isOperating, isLoadingSync, playback state)
- Buttons rendered in UI with proper labels
</done>
</task>
<task type="auto">
<name>Task 2: Implement visibility-aware polling</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add polling logic following Phase 3 Backing Track pattern:
**1. Add polling effect:**
```javascript
// Polling for position and duration
useEffect(() => {
if (!jamClient || !fqIdRef.current || !jamTrackState.isPlaying) return;
// Check if tab is visible
const isVisible = document.visibilityState === 'visible';
const pollInterval = isVisible ? 500 : 2000;
const intervalId = setInterval(async () => {
try {
// Fetch position and duration
const positionStr = await jamClient.JamTrackGetPositionMs(fqIdRef.current);
const durationStr = await jamClient.JamTrackGetDurationMs(fqIdRef.current);
const position = parseInt(positionStr, 10);
const duration = parseInt(durationStr, 10);
// Conditional update: only update if values changed
if (
position !== jamTrackState.currentPositionMs ||
duration !== jamTrackState.durationMs
) {
dispatch(setJamTrackState({
currentPositionMs: position,
durationMs: duration,
lastUpdate: Date.now()
}));
}
// End-of-track handling
if (duration > 0 && position >= duration) {
await jamClient.JamTrackStop(fqIdRef.current);
dispatch(setJamTrackState({
isPlaying: false,
isPaused: false,
currentPositionMs: 0
}));
}
} catch (err) {
console.error('[JamTrack] Polling error:', err);
// Don't setError - polling errors are non-critical
}
}, pollInterval);
return () => clearInterval(intervalId);
}, [jamClient, jamTrackState.isPlaying, jamTrackState.currentPositionMs, jamTrackState.durationMs, dispatch]);
// Listen for visibility changes to recreate interval
useEffect(() => {
const handleVisibilityChange = () => {
// Trigger polling effect to restart with new interval
// This is handled by the dependency array
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
```
**2. Add formatTime utility:**
```javascript
// Helper: Format milliseconds to MM:SS
const formatTime = (ms) => {
if (!ms || isNaN(ms)) return '00:00';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
```
**3. Update time display in render:**
```javascript
<p>
{formatTime(jamTrackState.currentPositionMs)} / {formatTime(jamTrackState.durationMs)}
</p>
```
**Follow Phase 3 patterns:**
- 500ms when visible, 2000ms when hidden
- parseInt before using position/duration strings
- Conditional update (only if values changed)
- End-of-track detection and reset
- No error state for polling failures (non-critical)
</action>
<verify>
1. grep "pollInterval = isVisible ? 500 : 2000" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (visibility-aware)
2. grep "parseInt(positionStr, 10)" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (parseInt before use)
3. grep "position !== jamTrackState.currentPositionMs" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (conditional update)
4. grep "formatTime" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (utility exists)
</verify>
<done>
- Visibility-aware polling implemented (500ms visible, 2000ms hidden)
- Polling fetches position and duration via jamClient
- parseInt applied before using string values
- Conditional state updates (only when values change)
- End-of-track handling (stop and reset)
- formatTime utility for MM:SS display
- Time display updated in render
- visibilitychange listener updates polling interval
</done>
</task>
<task type="auto">
<name>Task 3: Implement seek slider</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add seek slider following Phase 3 Backing Track pattern:
**1. handleSeek:**
```javascript
const handleSeek = useCallback(async (newPositionMs) => {
if (isOperating || !jamClient || !fqIdRef.current) return;
try {
setError(null);
// UAT-003 fix: if paused, store pending seek
if (jamTrackState.isPaused) {
pendingSeekRef.current = newPositionMs;
dispatch(setJamTrackState({ currentPositionMs: newPositionMs }));
return;
}
// If playing, seek immediately
await jamClient.JamTrackSeekMs(fqIdRef.current, newPositionMs);
dispatch(setJamTrackState({ currentPositionMs: newPositionMs }));
} catch (err) {
console.error('[JamTrack] Seek error:', err);
setError({ type: 'playback', message: 'Failed to seek' });
}
}, [isOperating, jamClient, jamTrackState.isPaused, dispatch]);
```
**2. Add slider to render:**
```javascript
<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>
```
**UAT-003 fix pattern:**
- If paused: store in pendingSeekRef, update UI immediately
- On resume (handlePlay): apply pendingSeekRef if set, then clear
- If playing: seek immediately via jamClient
**Follow Phase 3 patterns:**
- handleSeek wrapped in useCallback
- Disabled during operations and before duration loaded
- parseInt on slider value
- Error handling with user feedback
</action>
<verify>
1. grep "const handleSeek" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
2. grep "pendingSeekRef.current = newPositionMs" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (UAT-003 fix)
3. grep "type=\"range\"" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (slider exists)
4. grep "disabled={isOperating" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (slider disabled correctly)
</verify>
<done>
- handleSeek implemented with useCallback
- UAT-003 fix applied (pending seek while paused, apply on resume)
- Seek slider rendered with correct min/max/value
- Slider disabled during operations and before duration loaded
- onChange parses int before passing to handler
- Error handling for seek failures
- Works while playing (immediate seek) and while paused (pending seek)
</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] All 3 playback handlers implemented (play/pause/stop)
- [ ] Visibility-aware polling working (500ms/2000ms)
- [ ] Position and duration update in real-time
- [ ] Seek slider functional with UAT-003 fix
- [ ] No errors when using controls
</verification>
<success_criteria>
- Playback controls complete: play/pause/stop with jamClient integration
- Visibility-aware polling: 500ms visible, 2000ms hidden, conditional updates
- Seek slider functional with UAT-003 fix (pending seek while paused)
- End-of-track handling (stop and reset when position >= duration)
- formatTime utility for MM:SS display
- All handlers use useCallback for performance
- Error handling with user-friendly messages
- Buttons and slider disabled appropriately
- Player matches Backing Track feel and quality
</success_criteria>
<output>
After completion, create `.planning/phases/05-jamtrack-implementation/05-03-SUMMARY.md`
</output>

View File

@ -0,0 +1,381 @@
---
phase: 05-jamtrack-implementation
plan: 04
type: execute
---
<objective>
Implement mixdown selection and download/sync UI for JamTrack player.
Purpose: Allow users to browse and select mixdowns (master/custom/stems), display download progress with 6-state sync machine UI, and handle download errors with retry capability.
Output: Complete mixdown picker and download UI with progress tracking and error handling.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-jamtrack-implementation/05-CONTEXT.md
@.planning/phases/05-jamtrack-implementation/05-03-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md
@.planning/phases/04-jamtrack-research-design/REDUX_DESIGN.md
@.planning/codebase/JAMTRACK_API.md
@.planning/codebase/JAMTRACK_LEGACY.md
@jam-ui/src/components/client/JKSessionJamTrackPlayer.js
**From Phase 4 design:**
- Mixdown types: master (single audio), custom-mix (merged stems), stem (single instrument)
- Mixdown picker shows hierarchy: master at top, then custom mixes, then stems
- Download/sync states: idle → checking → downloading → keying → synchronized → error
- pickMyPackage() logic filters by file_type == 'ogg', encrypt_type == 'jkz', sample_rate match
- Download progress: percentage (0-100%), step indicator (X of Y steps)
- Cancel button: calls JamTrackCancelDownload()
- Retry button: re-triggers downloadJamTrack thunk
**From legacy implementation:**
- Mixdown API: JamTrackGetMixdowns(jamTrackId) returns array
- Each mixdown has: id, name, type, packageId
- Master mixdown: type 'master', name usually 'Master Mix'
- Custom mixes: type 'custom', user-created
- Stems: type 'stem', individual instruments
</context>
<tasks>
<task type="auto">
<name>Task 1: Fetch and organize mixdowns</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add mixdown fetching logic to initialization:
**1. Fetch mixdowns in initializePlayer (after check sync):**
```javascript
// After checkJamTrackSync and before loadJamTrack...
// Fetch available mixdowns
try {
const mixdowns = await jamClient.JamTrackGetMixdowns(jamTrack.id);
if (mixdowns && mixdowns.length > 0) {
// Organize into hierarchy: master, custom mixes, stems
const organized = {
master: mixdowns.find(m => m.type === 'master') || null,
customMixes: mixdowns.filter(m => m.type === 'custom' || m.type === 'custom-mix'),
stems: mixdowns.filter(m => m.type === 'stem')
};
dispatch(setAvailableMixdowns(mixdowns));
// Set initial mixdown if not already selected
if (!selectedMixdownId) {
const defaultMixdown = organized.master || (mixdowns[0] || null);
if (defaultMixdown) {
setSelectedMixdownId(defaultMixdown.id);
dispatch(setActiveMixdown(defaultMixdown));
}
} else {
// Find and set the initially selected mixdown
const mixdown = mixdowns.find(m => m.id === selectedMixdownId);
if (mixdown) {
dispatch(setActiveMixdown(mixdown));
}
}
}
} catch (err) {
console.error('[JamTrack] Failed to fetch mixdowns:', err);
// Non-fatal - can still play default mixdown
}
```
**2. Add import:**
```javascript
import { setAvailableMixdowns, setActiveMixdown } from '../../store/features/activeSessionSlice';
```
**Follow patterns:**
- Fetch mixdowns during initialization (not on every render)
- Organize into master/customMixes/stems hierarchy
- Set default mixdown if none selected (prefer master)
- Non-fatal error (can still play without explicit selection)
- Store in Redux for shared access
</action>
<verify>
1. grep "JamTrackGetMixdowns" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (API call exists)
2. grep "organized = {" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (hierarchy created)
3. grep "dispatch(setAvailableMixdowns" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (stored in Redux)
4. grep "import.*setAvailableMixdowns" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (import added)
</verify>
<done>
- Mixdowns fetched via JamTrackGetMixdowns during initialization
- Organized into master/customMixes/stems hierarchy
- Default mixdown selected (prefer master, fallback to first available)
- availableMixdowns stored in Redux
- activeMixdown set in Redux
- Non-fatal error handling (can play without explicit selection)
</done>
</task>
<task type="auto">
<name>Task 2: Implement mixdown picker UI</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add mixdown picker UI and selection logic:
**1. handleMixdownChange:**
```javascript
const handleMixdownChange = useCallback(async (mixdownId) => {
if (isOperating || !jamClient || !fqIdRef.current) return;
try {
setIsOperating(true);
setError(null);
// Find the new mixdown
const mixdown = availableMixdowns.find(m => m.id === mixdownId);
if (!mixdown) {
throw new Error('Mixdown not found');
}
// Update local state
setSelectedMixdownId(mixdownId);
dispatch(setActiveMixdown(mixdown));
// If currently playing, stop and restart with new mixdown
if (jamTrackState.isPlaying || jamTrackState.isPaused) {
await jamClient.JamTrackStop(fqIdRef.current);
await dispatch(loadJamTrack({
jamTrack,
mixdownId,
autoPlay: true,
jamClient
})).unwrap();
}
} catch (err) {
console.error('[JamTrack] Mixdown change error:', err);
setError({ type: 'playback', message: 'Failed to change mixdown' });
} finally {
if (mountedRef.current) {
setIsOperating(false);
}
}
}, [isOperating, jamClient, jamTrack, jamTrackState, availableMixdowns, dispatch]);
```
**2. Add mixdown picker to render (after playback controls):**
```javascript
{availableMixdowns.length > 0 && (
<div style={{ marginTop: '10px' }}>
<label>Mixdown:</label>
<select
value={selectedMixdownId || ''}
onChange={(e) => handleMixdownChange(parseInt(e.target.value, 10))}
disabled={isOperating || isLoadingSync}
>
{availableMixdowns
.slice() // Don't mutate original array
.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>
</div>
)}
```
**Follow patterns:**
- useCallback for handler
- Stop current playback before changing mixdown
- Restart playback with new mixdown if was playing
- Disabled during operations
- Sorted display: master → custom mixes → stems
- Visual indicators (emojis) for mixdown types
</action>
<verify>
1. grep "const handleMixdownChange" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
2. grep "<select" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (picker UI exists)
3. grep "sort((a, b)" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (hierarchy sorting)
4. grep "🎵" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (visual indicators)
</verify>
<done>
- handleMixdownChange implemented with useCallback
- Mixdown change stops current playback and restarts with new mixdown
- Mixdown picker dropdown rendered
- Sorted display: master → custom mixes → stems (alphabetical within types)
- Visual indicators for mixdown types (🎵 master, 🎨 custom, 🎸 stem)
- Disabled during operations and loading
- selectedMixdownId updated in local state
- activeMixdown updated in Redux
</done>
</task>
<task type="auto">
<name>Task 3: Implement download/sync UI</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Add download/sync UI with 6-state machine visualization:
**1. handleCancelDownload:**
```javascript
const handleCancelDownload = useCallback(async () => {
if (!jamClient || !fqIdRef.current) return;
try {
await jamClient.JamTrackCancelDownload(fqIdRef.current);
dispatch(setDownloadState({ state: 'idle', progress: 0 }));
} catch (err) {
console.error('[JamTrack] Cancel download error:', err);
setError({ type: 'download', message: 'Failed to cancel download' });
}
}, [jamClient, dispatch]);
```
**2. handleRetryDownload:**
```javascript
const handleRetryDownload = useCallback(async () => {
if (isOperating || !jamClient) return;
try {
setIsOperating(true);
setError(null);
// Clear error state
dispatch(setDownloadState({ state: 'idle', error: null }));
// Retry load (will trigger download if not synced)
await dispatch(loadJamTrack({
jamTrack,
mixdownId: selectedMixdownId,
autoPlay: false,
jamClient
})).unwrap();
} catch (err) {
console.error('[JamTrack] Retry download error:', err);
setError({ type: 'download', message: 'Retry failed' });
} finally {
if (mountedRef.current) {
setIsOperating(false);
}
}
}, [isOperating, jamClient, jamTrack, selectedMixdownId, dispatch]);
```
**3. Add download UI to render (above playback controls):**
```javascript
{/* 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 === 'downloading' && 'Downloading JamTrack...'}
{downloadState.state === 'keying' && 'Requesting decryption keys...'}
{downloadState.state === 'error' && 'Download Failed'}
</h4>
{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>
)}
```
**Follow patterns:**
- 6 states visualized: idle (hidden), checking, downloading, keying, synchronized (hidden), error
- Progress bar for downloading state (0-100%)
- Step indicator when available (X of Y steps)
- Cancel button during download
- Retry button on error
- Clear visual feedback for each state
</action>
<verify>
1. grep "const handleCancelDownload" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
2. grep "const handleRetryDownload" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (handler exists)
3. grep "<progress" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (progress bar exists)
4. grep "downloadState.state === 'checking'" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (state machine UI)
</verify>
<done>
- 6-state sync machine UI implemented (checking, downloading, keying, error states shown)
- Progress bar shows download percentage (0-100%)
- Step indicator shows "Step X of Y" when available
- Cancel button calls JamTrackCancelDownload during download
- Retry button on error re-triggers loadJamTrack
- Clear visual feedback for each state
- Error state shows error message and retry option
- idle and synchronized states hidden (normal playback)
</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] Mixdowns fetched and organized during initialization
- [ ] Mixdown picker displays master/custom/stems hierarchy
- [ ] Mixdown selection changes playback
- [ ] Download UI shows all 6 states correctly
- [ ] Progress bar and step indicator update during download
- [ ] Cancel and retry buttons work
</verification>
<success_criteria>
- Mixdowns fetched via JamTrackGetMixdowns and organized into hierarchy
- Mixdown picker dropdown with sorted display (master → custom → stems)
- handleMixdownChange stops/restarts playback with new mixdown
- Download/sync UI displays 6-state machine (checking, downloading, keying, error)
- Progress bar shows 0-100% during download
- Step indicator shows current/total steps
- Cancel button works during download
- Retry button works on error
- Visual indicators for mixdown types
- All handlers use useCallback
- Error handling for mixdown change, cancel, retry
</success_criteria>
<output>
After completion, create `.planning/phases/05-jamtrack-implementation/05-04-SUMMARY.md`
</output>

View File

@ -0,0 +1,423 @@
---
phase: 05-jamtrack-implementation
plan: 05
type: execute
---
<objective>
Add comprehensive error handling, apply performance optimizations, and conduct user acceptance testing.
Purpose: Complete JamTrack player with production-ready error handling, React performance optimizations from Phase 3, and validate all functionality through systematic UAT.
Output: Production-ready JamTrack player with documented known issues (if any).
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
@./.claude/get-shit-done/references/checkpoints.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-jamtrack-implementation/05-CONTEXT.md
@.planning/phases/05-jamtrack-implementation/05-04-SUMMARY.md
@.planning/phases/04-jamtrack-research-design/IMPLEMENTATION_ROADMAP.md
@.planning/phases/03-backing-track-finalization/03-03-SUMMARY.md
@jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
@jam-ui/src/components/client/JKSessionJamTrackPlayer.js
**From Phase 3 patterns:**
- Error types: file (red), network (red), playback (yellow), general (yellow)
- Error display: banner with dismiss and retry buttons
- Network resilience: stop polling after 3 consecutive failures
- Edge cases: invalid files, null jamClient, disconnected native client
- Cleanup on unmount: prevent stale state bugs
- Performance: useCallback, useMemo, conditional updates, remove diagnostic logging
**From Phase 4 risk analysis:**
- HIGH risks: Download/sync state machine complexity, native client race conditions
- Expected: 2-4 deferred issues similar to Phase 3 (end-of-track double-click, loop not working)
- UAT deferral threshold: Defer minor only, fix medium+ issues
**From Phase 5 context:**
- Match Backing Track player quality bar
- All three pillars equally important (download, playback, mixdown selection)
</context>
<tasks>
<task type="auto">
<name>Task 1: Add comprehensive error handling</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Enhance error handling throughout the component:
**1. Add error types:**
```javascript
const ERROR_TYPES = {
FILE: 'file', // Red - critical
NETWORK: 'network', // Red - critical
DOWNLOAD: 'download', // Red - critical
PLAYBACK: 'playback', // Yellow - warning
GENERAL: 'general' // Yellow - warning
};
```
**2. Enhance error display in render:**
```javascript
{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>
</div>
)}
```
**3. Add handleRetryError:**
```javascript
const handleRetryError = useCallback(async () => {
if (!error || isOperating) return;
setError(null);
// Retry based on error type
if (error.type === ERROR_TYPES.DOWNLOAD || error.type === ERROR_TYPES.FILE) {
await handleRetryDownload();
} else if (error.type === ERROR_TYPES.NETWORK) {
// Re-initialize player
if (jamTrack && jamClient) {
const fqId = await buildFqId();
fqIdRef.current = fqId;
await dispatch(loadJamTrack({ jamTrack, mixdownId: selectedMixdownId, autoPlay: false, jamClient }));
}
}
}, [error, isOperating, jamTrack, jamClient, selectedMixdownId, handleRetryDownload, buildFqId, dispatch]);
```
**4. Add network resilience to polling:**
```javascript
const consecutiveFailuresRef = useRef(0);
// In polling effect, wrap try/catch:
try {
// ... existing polling logic ...
consecutiveFailuresRef.current = 0; // Reset on success
} catch (err) {
console.error('[JamTrack] Polling error:', err);
consecutiveFailuresRef.current += 1;
if (consecutiveFailuresRef.current >= 3) {
setError({ type: ERROR_TYPES.NETWORK, message: 'Lost connection to native client. Check if JamKazam is running.' });
// Stop polling by setting isPlaying to false
dispatch(setJamTrackState({ isPlaying: false }));
}
}
```
**5. Add edge case handling:**
```javascript
// In initializePlayer, before any jamClient calls:
if (!jamClient) {
setError({ type: ERROR_TYPES.GENERAL, message: 'Native client not available. Please ensure JamKazam is running.' });
return;
}
if (!jamTrack || !jamTrack.id) {
setError({ type: ERROR_TYPES.FILE, message: 'Invalid JamTrack data' });
return;
}
```
**Follow Phase 3 patterns:**
- Typed errors with color coding (red/yellow)
- User-friendly error messages
- Retry buttons for recoverable errors
- Network resilience (3 consecutive failures)
- Edge case validation (null jamClient, invalid jamTrack)
</action>
<verify>
1. grep "const ERROR_TYPES" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (error types defined)
2. grep "consecutiveFailuresRef" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (network resilience)
3. grep "handleRetryError" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (retry handler)
4. Count error.type checks: should have file, network, download, playback, general
</verify>
<done>
- ERROR_TYPES constant defined (file, network, download, playback, general)
- Error display color-coded (red for critical, yellow for warnings)
- Retry button for recoverable errors (network, download, file)
- handleRetryError implemented with type-specific retry logic
- Network resilience: stop polling after 3 consecutive failures
- Edge case validation: null jamClient, invalid jamTrack data
- User-friendly error messages
- Dismiss button for all errors
</done>
</task>
<task type="auto">
<name>Task 2: Apply performance optimizations</name>
<files>jam-ui/src/components/client/JKSessionJamTrackPlayer.js</files>
<action>
Apply React performance optimizations from Phase 3:
**1. Add useMemo for computed values:**
```javascript
// Format time with useMemo
const formattedPosition = useMemo(
() => formatTime(jamTrackState.currentPositionMs),
[jamTrackState.currentPositionMs]
);
const formattedDuration = useMemo(
() => formatTime(jamTrackState.durationMs),
[jamTrackState.durationMs]
);
// Compute progress percentage
const progressPercent = useMemo(() => {
if (!jamTrackState.durationMs || jamTrackState.durationMs === 0) return 0;
return Math.round((jamTrackState.currentPositionMs / jamTrackState.durationMs) * 100);
}, [jamTrackState.currentPositionMs, jamTrackState.durationMs]);
```
**2. Verify all handlers use useCallback:**
- handlePlay ✓ (already has useCallback)
- handlePause ✓
- handleStop ✓
- handleSeek ✓
- handleMixdownChange ✓
- handleCancelDownload ✓
- handleRetryDownload ✓
- handleRetryError (add if not already)
- buildFqId ✓
**3. Verify conditional state updates in polling:**
```javascript
// Already implemented in Plan 3, verify:
if (
position !== jamTrackState.currentPositionMs ||
duration !== jamTrackState.durationMs
) {
dispatch(setJamTrackState({ ... }));
}
```
**4. Verify visibility-aware polling:**
```javascript
// Already implemented in Plan 3, verify:
const isVisible = document.visibilityState === 'visible';
const pollInterval = isVisible ? 500 : 2000;
```
**5. Remove all diagnostic logging:**
```javascript
// Search for console.log statements and remove unless they're error logs
// Keep: console.error for error tracking
// Remove: console.log for debugging (e.g., "[JamTrack] Not synchronized, will download")
```
**6. Add React.memo if component re-renders unnecessarily:**
```javascript
// At export, wrap in memo if needed:
export default React.memo(JKSessionJamTrackPlayer);
```
**Follow Phase 3 patterns:**
- useMemo for computed values (formatted time, progress percentage)
- useCallback for all event handlers
- Conditional state updates (only update if values changed)
- Visibility-aware polling (500ms/2000ms)
- Clean console output (no diagnostic logs)
</action>
<verify>
1. grep "useMemo" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (computed values memoized)
2. grep "useCallback" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (all handlers wrapped)
3. grep "console.log" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (should only find console.error, not console.log)
4. grep "React.memo" jam-ui/src/components/client/JKSessionJamTrackPlayer.js (component memoized)
</verify>
<done>
- useMemo added for formattedPosition, formattedDuration, progressPercent
- All handlers verified to use useCallback
- Conditional state updates verified in polling
- Visibility-aware polling verified (500ms/2000ms)
- Diagnostic console.log removed (only console.error remains)
- Component wrapped in React.memo at export
- Performance optimizations match Phase 3 Backing Track quality
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Complete JamTrack player with download, playback, mixdown selection, error handling, and performance optimizations</what-built>
<how-to-verify>
**Test Environment:**
1. Ensure JamKazam native client is running
2. Start jam-ui dev server: `cd jam-ui && npm run start`
3. Navigate to a session with JamTracks available
4. Open JamTrack player (modal or popup mode)
**Test Cases (40+ scenarios):**
**Initialization (4 cases):**
- [ ] JamTrack opens in modal mode
- [ ] JamTrack opens in popup mode (if supported)
- [ ] JamTrack already synced → shows player immediately
- [ ] JamTrack not synced → shows download UI
**Download/Sync (6 cases):**
- [ ] Download progress shows percentage (0-100%)
- [ ] Download progress shows step indicator ("Step X of Y")
- [ ] Download state transitions: checking → downloading → keying → synchronized
- [ ] Download completes, player becomes available
- [ ] Cancel button works during download (stops download)
- [ ] Download error shows error message with retry button
**Playback Controls (7 cases):**
- [ ] Play button starts playback
- [ ] Pause button pauses playback
- [ ] Stop button stops playback and resets position to 0
- [ ] Play after stop restarts from beginning
- [ ] Resume after pause continues from pause point
- [ ] Buttons disabled during operations (isOperating flag)
- [ ] Buttons disabled during sync check (isLoadingSync flag)
**Seek Slider (4 cases):**
- [ ] Seek slider moves during playback
- [ ] Drag seek slider to new position while playing → seeks immediately
- [ ] Drag seek slider while paused → updates UI, applies on resume (UAT-003 fix)
- [ ] Seek slider disabled before duration loads
**Mixdown Selection (5 cases):**
- [ ] Mixdown picker shows available mixdowns
- [ ] Mixdowns sorted: master → custom mixes → stems
- [ ] Visual indicators show mixdown types (🎵 🎨 🎸)
- [ ] Select different mixdown → playback stops and restarts
- [ ] Mixdown picker disabled during operations
**Display (5 cases):**
- [ ] Position displays in MM:SS format
- [ ] Duration displays in MM:SS format
- [ ] Seek slider position matches audio position
- [ ] Download progress updates in real-time
- [ ] Error messages display clearly
**Error Handling (5 cases):**
- [ ] Invalid JamTrack shows error
- [ ] Network failure during download shows error with retry
- [ ] Network failure during polling (after 3 failures) shows error
- [ ] Null jamClient shows "Native client not available" error
- [ ] Error dismiss button clears error
**Performance (4 cases):**
- [ ] No lag during playback
- [ ] Polling doesn't cause CPU spikes (check Activity Monitor)
- [ ] Visibility-aware polling: 500ms visible, 2000ms hidden (check intervals)
- [ ] Multiple JamTracks can be opened sequentially without issues
**Cleanup (4 cases):**
- [ ] Close modal → playback stops
- [ ] Close popup → playback stops
- [ ] Open different JamTrack → previous stops
- [ ] No console errors after interactions
**Known Issues from Phase 3 (check if they apply):**
- [ ] End-of-track restart requires double-click? (Minor - defer if present)
- [ ] Loop functionality? (Not in scope for Phase 5)
- [ ] Volume control in popup mode? (Not in scope - defer if issue)
**Pass Criteria:**
- Core functionality works (download, playback, mixdown selection)
- No critical errors (red errors should be rare, retryable)
- Performance acceptable (no lag, smooth playback)
- Known issues documented (minor issues OK to defer)
**If issues found:**
- CRITICAL (blocks functionality): Describe issue, I'll fix immediately
- MEDIUM (feature not working): Describe issue, decide fix now or defer
- MINOR (UX inconvenience): Document in ISSUES.md, defer to future work
</how-to-verify>
<resume-signal>Type "approved" if UAT passes, or describe issues found with severity (critical/medium/minor)</resume-signal>
</task>
</tasks>
<verification>
Before presenting UAT checkpoint:
- [ ] Error handling comprehensive (5 error types, retry logic, network resilience)
- [ ] Performance optimizations applied (useMemo, useCallback, conditional updates, clean logs)
- [ ] All code follows Phase 3 patterns
- [ ] Component ready for manual testing
</verification>
<success_criteria>
- Comprehensive error handling: 5 error types (file, network, download, playback, general)
- Error display color-coded (red for critical, yellow for warnings)
- Retry logic for recoverable errors
- Network resilience (stop polling after 3 failures)
- Edge case validation (null jamClient, invalid data)
- Performance optimizations: useMemo, useCallback, conditional updates, visibility-aware polling
- Diagnostic logging removed (only console.error)
- Component wrapped in React.memo
- UAT conducted with 40+ test cases
- Known issues documented (if any)
- Phase 5 complete: JamTrack player production-ready
</success_criteria>
<output>
After UAT completion, create `.planning/phases/05-jamtrack-implementation/05-05-SUMMARY.md`:
# Phase 5 Plan 5: Error Handling & Final UAT Summary
**[Substantive one-liner - production-ready JamTrack player]**
## Accomplishments
- Comprehensive error handling with 5 error types
- Performance optimizations applied
- UAT conducted: [X]/40+ test cases passed
- Known issues documented (if any)
## Files Created/Modified
- `jam-ui/src/components/client/JKSessionJamTrackPlayer.js` - Error handling + performance
## UAT Results
**Working Features:** [list]
**Known Issues (Deferred):** [if any, with severity and root cause]
## Issues Encountered
[Problems during UAT and resolutions]
## Phase 5 Complete
JamTrack player is production-ready with:
- ✅ Download/sync with progress tracking
- ✅ Playback controls (play/pause/stop/seek)
- ✅ Mixdown selection (master/custom/stems)
- ✅ Comprehensive error handling
- ✅ Performance optimizations
- ⚠️ Known issues documented (if any)
**If UAT revealed issues requiring fixes:** Create `05-05-ISSUES.md` documenting deferred issues for future work.
## Next Phase Readiness
Phase 5 complete. Project ready for next phase (Phase 6: Metronome Research & Design).
</output>