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:
parent
81feb2666e
commit
cc32abcaba
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue