diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 57735fbc6..c90b8dea9 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -33,7 +33,17 @@
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(node -c:*)",
- "Skill(gsd:new-project)"
+ "Skill(gsd:new-project)",
+ "Skill(gsd:create-roadmap)",
+ "Bash(if [ -f .planning/ROADMAP.md ])",
+ "Bash(then)",
+ "Bash(else)",
+ "Bash(exit 1)",
+ "Bash(fi)",
+ "Skill(gsd:execute-plan)",
+ "Skill(gsd:plan-phase)",
+ "Skill(gsd:progress)",
+ "Bash(git rev-parse:*)"
]
}
}
diff --git a/.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md b/.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md
new file mode 100644
index 000000000..e363399fe
--- /dev/null
+++ b/.planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md
@@ -0,0 +1,679 @@
+# JamTrack React Component Design
+
+Comprehensive component architecture for JamTrack functionality in jam-ui, following established Backing Track patterns with extensions for JamTrack-specific complexity.
+
+## 1. JKSessionJamTrackPlayer Component (NEW - Primary Deliverable)
+
+### Purpose
+Full-featured JamTrack player with playback controls, mixdown selection, download/sync progress, and error handling.
+
+### Rendering Modes
+- **Modal mode**: Player rendered in modal dialog (default)
+- **Popup mode**: Player rendered in separate popup window (follows Backing Track pattern)
+
+### Props Interface
+
+```typescript
+interface JKSessionJamTrackPlayerProps {
+ // Visibility control
+ isOpen: boolean;
+ onClose: () => void;
+ isPopup?: boolean; // Default: false
+
+ // JamTrack data
+ jamTrack: JamTrack | string; // Object (modal) or fqId string (popup)
+
+ // Dependencies (passed as props, not Redux)
+ jamClient: JamClient; // Non-serializable
+ session: Session;
+ currentUser: User;
+
+ // Optional configuration
+ initialMixdownId?: number; // Pre-select specific mixdown
+ autoPlay?: boolean; // Auto-play on load (default: false)
+}
+
+interface JamTrack {
+ id: number;
+ name: string;
+ artist: string;
+ jmep?: JMEPData; // Tempo/pitch modifications
+ mixdowns: Mixdown[];
+ albumArt?: string;
+}
+
+interface JMEPData {
+ tempo: number; // BPM or percentage
+ pitch: number; // Semitones shift
+}
+
+interface Mixdown {
+ id: number;
+ name: string;
+ type: 'master' | 'custom' | 'stem';
+ packages: MixdownPackage[];
+}
+
+interface MixdownPackage {
+ id: number;
+ file_type: 'ogg';
+ encrypt_type: 'jkz';
+ sample_rate: 44 | 48;
+ signing_state: 'SIGNING_TIMEOUT' | 'QUEUED_TIMEOUT' | 'QUIET_TIMEOUT' | 'ERROR' | 'SIGNED';
+ packaging_steps?: number;
+ current_packaging_step?: number;
+}
+```
+
+### State Management
+
+**Local State (Component-specific UI):**
+```javascript
+// Playback state (mirrors jamClient, updated via polling)
+const [isPlaying, setIsPlaying] = useState(false);
+const [isPaused, setIsPaused] = useState(false);
+const [currentPositionMs, setCurrentPositionMs] = useState(0);
+const [durationMs, setDurationMs] = useState(0);
+const [currentTime, setCurrentTime] = useState('0:00');
+const [duration, setDuration] = useState('0:00');
+
+// Mixdown selection
+const [selectedMixdownId, setSelectedMixdownId] = useState(null);
+const [availableMixdowns, setAvailableMixdowns] = useState([]);
+
+// Error handling
+const [error, setError] = useState(null);
+const [errorType, setErrorType] = useState(null); // 'file' | 'network' | 'playback' | 'sync' | 'general'
+
+// Loading states
+const [isLoadingDuration, setIsLoadingDuration] = useState(true);
+const [isOperating, setIsOperating] = useState(false); // Prevent rapid clicks
+const [isSyncing, setIsSyncing] = useState(false); // Download/keying in progress
+
+// Performance optimization
+const [isVisible, setIsVisible] = useState(true); // Tab visibility
+
+// Refs (non-reactive state)
+const pendingSeekPositionRef = useRef(null); // UAT-003 fix (seek while paused)
+const consecutivePollingErrorsRef = useRef(0); // Network resilience
+const pollingIntervalRef = useRef(null); // Cleanup tracking
+```
+
+**Redux State (Shared across components):**
+```javascript
+// From mediaSlice
+const jamTrackState = useSelector(selectJamTrackState); // Real-time WebSocket updates
+const downloadState = useSelector(selectDownloadState); // Sync progress
+
+// From activeSessionSlice
+const selectedJamTrack = useSelector(selectSelectedJamTrack); // Full JamTrack object
+const jamTrackStems = useSelector(selectJamTrackStems); // After load
+
+// From sessionUISlice
+const jamTrackPlayerOpen = useSelector(selectJamTrackPlayerOpen);
+```
+
+### Sub-Components
+
+#### 1. MixdownSelector
+```jsx
+
+```
+
+**Features:**
+- Dropdown showing: Master, Custom Mixes, Individual Stems
+- Visual indicator for currently playing mixdown
+- Disabled during operations to prevent mid-playback switches
+- Groups mixdowns by type (Master / Custom / Stems)
+
+#### 2. PlaybackControls
+```jsx
+
+```
+
+**Features:**
+- Play/pause/stop buttons (FontAwesome icons: faPlay, faPause, faStop)
+- Disabled states during loading/error
+- Visual feedback for current playback state
+- Follows Backing Track button styling
+
+#### 3. SeekBar
+```jsx
+
+```
+
+**Features:**
+- Range slider (0 to durationMs)
+- Drag-to-position with preview tooltip
+- Time display: current / duration (MM:SS format)
+- UAT-003 fix: Pending seek while paused pattern
+- Visual progress bar
+
+#### 4. DownloadSyncProgress
+```jsx
+
+```
+
+**Features:**
+- Progress bar for download (0-100%)
+- State indicators: Checking, Downloading, Keying, Synchronized
+- Current step display: "Downloading... 45%" or "Requesting encryption keys..."
+- Cancel button for long downloads
+- Error state with retry button
+
+#### 5. ErrorBanner
+```jsx
+
+```
+
+**Features:**
+- Color-coded by type: Red (file/network/sync), Yellow (playback/general)
+- Dismiss button (×)
+- Retry button (only for file/network/sync errors)
+- Icon indicators (⚠️)
+- Follows Backing Track error styling
+
+### Component Lifecycle
+
+```javascript
+useEffect(() => {
+ // 1. Initialize on open
+ if ((isOpen || isPopup) && jamTrack && jamClient) {
+ initializePlayer();
+ }
+
+ // 2. Cleanup on close/unmount
+ return () => {
+ if (jamClient) {
+ jamClient.JamTrackStopPlay();
+ clearPolling();
+ }
+ };
+}, [isOpen, isPopup, jamTrack, jamClient]);
+
+const initializePlayer = async () => {
+ try {
+ // Reset state
+ setIsPlaying(false);
+ setError(null);
+ setIsLoadingDuration(true);
+
+ // Build fqId
+ const sampleRate = await jamClient.GetSampleRate(); // 44 or 48
+ const sampleRateStr = sampleRate === 48 ? '48' : '44';
+ const fqId = `${jamTrack.id}-${sampleRateStr}`;
+
+ // Check sync state
+ const trackDetail = await jamClient.JamTrackGetTrackDetail(fqId);
+ if (!trackDetail.key_state || trackDetail.key_state !== 'AVAILABLE') {
+ // Not synchronized - show download UI
+ setIsSyncing(true);
+ await initiateDownload(jamTrack, fqId);
+ return;
+ }
+
+ // Load JMEP if present
+ if (jamTrack.jmep) {
+ await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
+ }
+
+ // Load JamTrack
+ const result = await jamClient.JamTrackPlay(fqId);
+ if (!result) {
+ throw new Error('Failed to load JamTrack');
+ }
+
+ // Fetch duration
+ const durationMs = parseInt(await jamClient.SessionGetJamTracksPlayDurationMs());
+ setDurationMs(durationMs);
+ setDuration(formatTime(durationMs));
+ setIsLoadingDuration(false);
+
+ // Start polling
+ startPolling();
+
+ } catch (err) {
+ handleError('file', 'Failed to load JamTrack', err);
+ setIsLoadingDuration(false);
+ }
+};
+```
+
+### Playback Controls Implementation
+
+```javascript
+const handlePlay = useCallback(async () => {
+ if (!jamClient || isOperating) return;
+
+ try {
+ setIsOperating(true);
+ clearError();
+
+ // Apply pending seek if paused (UAT-003 fix)
+ if (isPaused && pendingSeekPositionRef.current !== null) {
+ await jamClient.SessionStopPlay();
+ await jamClient.SessionJamTrackSeekMs(pendingSeekPositionRef.current);
+ pendingSeekPositionRef.current = null;
+ }
+
+ await jamClient.SessionStartPlay();
+ setIsPlaying(true);
+ setIsPaused(false);
+ startPolling();
+
+ } catch (err) {
+ handleError('playback', 'Failed to start playback', err);
+ } finally {
+ setIsOperating(false);
+ }
+}, [jamClient, isOperating, isPaused]);
+
+const handlePause = useCallback(async () => {
+ if (!jamClient || isOperating) return;
+
+ try {
+ setIsOperating(true);
+ await jamClient.SessionPausePlay();
+ setIsPlaying(false);
+ setIsPaused(true);
+ stopPolling();
+ } catch (err) {
+ handleError('playback', 'Failed to pause playback', err);
+ } finally {
+ setIsOperating(false);
+ }
+}, [jamClient, isOperating]);
+
+const handleStop = useCallback(async () => {
+ if (!jamClient || isOperating) return;
+
+ try {
+ setIsOperating(true);
+ await jamClient.SessionStopPlay();
+ setIsPlaying(false);
+ setIsPaused(false);
+ setCurrentPositionMs(0);
+ setCurrentTime('0:00');
+ pendingSeekPositionRef.current = null;
+ stopPolling();
+ } catch (err) {
+ handleError('playback', 'Failed to stop playback', err);
+ } finally {
+ setIsOperating(false);
+ }
+}, [jamClient, isOperating]);
+
+const handleSeek = useCallback(async (positionMs) => {
+ if (!jamClient) return;
+
+ try {
+ await jamClient.SessionJamTrackSeekMs(positionMs);
+ setCurrentPositionMs(positionMs);
+ setCurrentTime(formatTime(positionMs));
+
+ // UAT-003: Verify seek applied
+ if (isPaused) {
+ const actualPosition = parseInt(await jamClient.SessionCurrrentJamTrackPlayPosMs());
+ if (Math.abs(actualPosition - positionMs) > 100) {
+ // Seek didn't apply while paused - store for resume
+ pendingSeekPositionRef.current = positionMs;
+ }
+ }
+ } catch (err) {
+ handleError('playback', 'Failed to seek', err);
+ }
+}, [jamClient, isPaused, formatTime]);
+```
+
+### Polling Pattern (Performance Optimized)
+
+```javascript
+const startPolling = useCallback(() => {
+ stopPolling(); // Clear any existing interval
+
+ const poll = async () => {
+ if (!jamClient) return;
+
+ try {
+ // Check if still playing
+ const playing = await jamClient.JamTrackIsPlaying();
+ if (!playing) {
+ setIsPlaying(false);
+ stopPolling();
+ return;
+ }
+
+ // Get current position
+ const position = parseInt(await jamClient.SessionCurrrentJamTrackPlayPosMs());
+
+ // Only update if changed (lazy state update)
+ if (position !== currentPositionMs) {
+ setCurrentPositionMs(position);
+ setCurrentTime(formatTime(position));
+ }
+
+ // Check if reached end of track
+ if (durationMs > 0 && position >= durationMs - 100) {
+ // End of track - stop playback
+ await jamClient.SessionStopPlay();
+ setIsPlaying(false);
+ setCurrentPositionMs(0);
+ setCurrentTime('0:00');
+ stopPolling();
+ }
+
+ // Reset error counter on success
+ consecutivePollingErrorsRef.current = 0;
+
+ } catch (err) {
+ consecutivePollingErrorsRef.current += 1;
+
+ if (consecutivePollingErrorsRef.current >= 3) {
+ handleError('network', 'Lost connection to jamClient', err);
+ stopPolling();
+ }
+ }
+ };
+
+ // Visibility-aware polling: 500ms visible, 2000ms hidden
+ const interval = isVisible ? 500 : 2000;
+ pollingIntervalRef.current = setInterval(poll, interval);
+}, [jamClient, currentPositionMs, durationMs, isVisible, formatTime]);
+
+const stopPolling = useCallback(() => {
+ if (pollingIntervalRef.current) {
+ clearInterval(pollingIntervalRef.current);
+ pollingIntervalRef.current = null;
+ }
+}, []);
+
+// Update polling interval on visibility change
+useEffect(() => {
+ if (isPlaying && pollingIntervalRef.current) {
+ startPolling(); // Restart with new interval
+ }
+}, [isVisible, isPlaying]);
+```
+
+### Mixdown Selection
+
+```javascript
+const handleMixdownChange = useCallback(async (mixdownId) => {
+ if (!jamClient || isOperating) return;
+
+ try {
+ setIsOperating(true);
+ const wasPlaying = isPlaying;
+
+ // Stop current playback
+ if (wasPlaying) {
+ await jamClient.SessionStopPlay();
+ setIsPlaying(false);
+ }
+
+ // Unload current JamTrack
+ await jamClient.JamTrackStopPlay();
+
+ // Update selected mixdown in Redux
+ dispatch(setActiveMixdown(mixdownId));
+ setSelectedMixdownId(mixdownId);
+
+ // Build fqId
+ const sampleRate = await jamClient.GetSampleRate();
+ const sampleRateStr = sampleRate === 48 ? '48' : '44';
+ const fqId = `${jamTrack.id}-${sampleRateStr}`;
+
+ // Reload JamTrack with new mixdown
+ const result = await jamClient.JamTrackPlay(fqId);
+ if (!result) {
+ throw new Error('Failed to load JamTrack with new mixdown');
+ }
+
+ // Resume playback if was playing
+ if (wasPlaying) {
+ await jamClient.SessionStartPlay();
+ setIsPlaying(true);
+ startPolling();
+ }
+
+ } catch (err) {
+ handleError('playback', 'Failed to switch mixdown', err);
+ } finally {
+ setIsOperating(false);
+ }
+}, [jamClient, isOperating, isPlaying, jamTrack, dispatch]);
+```
+
+## 2. JKSessionJamTrackModal Component (EXISTS - Minor Updates)
+
+### Current State
+- ✓ Functional selection UI with search/autocomplete
+- ✓ Pagination (10 items per page)
+- ✓ REST API integration (`getPurchasedJamTracks`)
+
+### Potential Updates for Phase 5
+
+**Add Download State Indicators:**
+```jsx
+
+
+// Show sync status icon
+{downloadState === 'synchronized' && }
+{downloadState === 'downloading' && }
+{downloadState === 'error' && }
+```
+
+**Add Mixdown Preview:**
+```jsx
+
+```
+
+**Integration Pattern:**
+```jsx
+const handleJamTrackSelect = useCallback((jamTrack) => {
+ // Close modal
+ toggle();
+
+ // Dispatch loadJamTrack thunk
+ dispatch(loadJamTrackThunk({
+ jamTrack,
+ mixdownId: selectedMixdownId, // If pre-selected in preview
+ jamClient
+ }));
+}, [toggle, dispatch, jamClient, selectedMixdownId]);
+```
+
+## 3. JKSessionJamTrackStems Component (EXISTS - Verify Completeness)
+
+### Purpose
+Display JamTrack stem tracks in session mixer view with individual controls.
+
+### Expected Structure
+```jsx
+const JKSessionJamTrackStems = ({ jamTrackStems, mixerHelper }) => {
+ return (
+
+ {jamTrackStems.map(stem => (
+
+
+
+
+
+ ))}
+
+ );
+};
+```
+
+### Verification Needed
+- [ ] Supports all mixer operations (volume, pan, mute, solo)
+- [ ] Handles stem track updates from WebSocket MIXER_CHANGES
+- [ ] Visual indication of active stems
+- [ ] Integration with useMixerHelper hook
+
+## 4. Component Hierarchy
+
+```
+JKSessionScreen
+├── Header
+│ └── Open Menu
+│ └── "Open JamTrack" → dispatch(setShowJamTrackModal(true))
+│
+├── JKSessionJamTrackModal (selection)
+│ ├── JKJamTracksAutoComplete (search)
+│ ├── JamTrackList (purchased tracks)
+│ └── onJamTrackSelect → dispatch(loadJamTrack())
+│
+├── JKSessionJamTrackPlayer (playback - NEW)
+│ ├── MixdownSelector
+│ │ └── Dropdown (Master / Custom Mixes / Stems)
+│ ├── DownloadSyncProgress
+│ │ ├── Progress bar
+│ │ ├── State indicator
+│ │ └── Cancel/Retry buttons
+│ ├── PlaybackControls
+│ │ ├── Play button
+│ │ ├── Pause button
+│ │ └── Stop button
+│ ├── SeekBar
+│ │ ├── Range slider
+│ │ └── Time display (current / duration)
+│ └── ErrorBanner
+│ ├── Error message
+│ ├── Dismiss button
+│ └── Retry button (conditional)
+│
+└── Mixer Section
+ └── JKSessionJamTrackStems (stems view)
+ └── SessionTrack[] (per stem)
+ ├── SessionTrackVU (VU meter)
+ ├── SessionTrackGain (volume/pan)
+ └── SessionTrackControls (mute/solo)
+```
+
+## 5. Props vs Redux Decision Matrix
+
+| Data | Storage | Rationale |
+|------|---------|-----------|
+| **jamClient** | Props | Non-serializable, can't be in Redux |
+| **session** | Props | Parent context, passed down |
+| **currentUser** | Props | Parent context, passed down |
+| **isOpen** | Props | Parent controls visibility |
+| **isPopup** | Props | Parent determines render mode |
+| **jamTrack** | Redux (`activeSession.selectedJamTrack`) | WebSocket updates, shared state |
+| **jamTrackState** | Redux (`media.jamTrackState`) | Real-time playback state from WebSocket |
+| **downloadState** | Redux (`media.downloadState`) | Shared across components (modal + player) |
+| **jamTrackStems** | Redux (`activeSession.jamTrackStems`) | Loaded after JamTrackPlay, used in mixer |
+| **availableMixdowns** | Redux (`activeSession.availableMixdowns`) | User's custom mixes, persisted |
+| **selectedMixdownId** | Local state | Player-specific UI state |
+| **error** | Local state | Player-specific errors |
+| **isPlaying** | Local state | Mirrors jamClient, updated via polling |
+| **currentPositionMs** | Local state | High-frequency updates, no global need |
+
+**Reasoning:**
+- **Props for context and control:** Non-serializable objects and parent-controlled visibility
+- **Redux for shared data:** WebSocket updates, multi-component access
+- **Local state for UI:** High-frequency polling data, component-specific errors
+
+## 6. Comparison to Backing Track Player
+
+| Feature | Backing Track | JamTrack | Notes |
+|---------|---------------|----------|-------|
+| **File selection** | Native dialog (`ShowSelectBackingTrackDialog`) | React modal (`JKSessionJamTrackModal`) | JamTrack: existing modal ready |
+| **Load API** | `SessionOpenBackingTrackFile(path)` | `JamTrackPlay(fqId)` | JamTrack: requires fqId format |
+| **Before load** | Immediate | Check sync state, download if needed | JamTrack: async download/keying flow |
+| **JMEP support** | No | Yes (`JamTrackLoadJmep` before play) | JamTrack: tempo/pitch modifications |
+| **Mixdown selection** | N/A | Dropdown selector (master/custom/stems) | JamTrack: NEW feature |
+| **Playback controls** | play/pause/stop/seek | Same + mixdown switcher | JamTrack: additional dropdown |
+| **Duration loading** | Single async call | Same (`SessionGetJamTracksPlayDurationMs`) | Same pattern |
+| **Position polling** | `SessionCurrentPlayPosMs()` | `SessionCurrrentJamTrackPlayPosMs()` | Similar APIs |
+| **Seek API** | `SessionTrackSeekMs(ms)` | `SessionJamTrackSeekMs(ms)` | Similar APIs |
+| **Volume control** | `SessionSetTrackVolumeData(mixer, vol)` | Same (per stem) | Same mixer integration |
+| **Loop control** | `SessionSetBackingTrackFileLoop(path, bool)` | TBD if supported | JamTrack: API unclear |
+| **Error types** | file, network, playback, general | Same + download, sync | JamTrack: +2 error types |
+| **Loading states** | isLoadingDuration, isOperating | Same + isSyncing | JamTrack: +1 loading state |
+| **Performance** | Visibility-aware polling (500ms/2s) | Same pattern | Apply Phase 3 optimizations |
+| **UAT-003 fix** | Pending seek while paused | Same pattern | Apply Phase 3 fix |
+| **Cleanup** | Stop on unmount | Stop JamTrack on unmount | Same pattern |
+| **Modal/Popup modes** | Both supported | Both supported | Apply Phase 3 fixes |
+
+**Key Differences:**
+1. **Mixdown selection:** JamTrack has dropdown, Backing Track does not
+2. **Download/sync flow:** JamTrack requires async download, Backing Track is immediate
+3. **JMEP loading:** JamTrack supports tempo/pitch, Backing Track does not
+4. **Error types:** JamTrack adds download/sync errors
+5. **API surface:** JamTrack has 15+ methods vs Backing Track's 8 methods
+
+**Reusable Patterns from Phase 3:**
+- ✓ Error banner with type-based colors and retry
+- ✓ Loading states (isLoadingDuration, isOperating)
+- ✓ Visibility-aware polling (500ms visible, 2s hidden)
+- ✓ useCallback for all handlers (prevent re-renders)
+- ✓ Conditional state updates (only update if changed)
+- ✓ Consecutive error tracking with refs
+- ✓ UAT-003 fix (pending seek while paused)
+- ✓ Cleanup on unmount (stop playback)
+- ✓ Modal and popup mode support
+
+---
+
+## Summary
+
+**Primary Component:** JKSessionJamTrackPlayer (NEW)
+- Based on JKSessionBackingTrackPlayer.js with extensions
+- Adds: Mixdown selector, download/sync progress, JMEP support
+- Reuses: Playback controls, seek bar, error handling, polling patterns
+
+**Supporting Components:**
+- JKSessionJamTrackModal (existing, minor updates)
+- JKSessionJamTrackStems (existing, verify completeness)
+
+**Design Principles:**
+1. Follow Phase 3 Backing Track patterns wherever possible
+2. Handle JamTrack-specific complexity (mixdowns, download, JMEP) with clear separation
+3. Props for control, Redux for shared data, local state for UI
+4. Performance optimizations from Phase 3 (visibility-aware polling, useCallback, lazy updates)
+5. Type-specific error handling with retry capabilities
+6. Comprehensive loading states for all async operations
diff --git a/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js b/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
index 398ecf157..8ef507376 100644
--- a/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
+++ b/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
-import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
+import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons';
import useMediaActions from '../../hooks/useMediaActions';