docs(04-02): design JamTrack component architecture

- Designed JKSessionJamTrackPlayer with props interface and state management
- Defined sub-components: MixdownSelector, PlaybackControls, SeekBar, DownloadSyncProgress, ErrorBanner
- Documented component lifecycle and initialization pattern
- Implemented playback controls with UAT-003 fix (pending seek while paused)
- Applied Phase 3 performance patterns (visibility-aware polling, useCallback, lazy updates)
- Created props vs Redux decision matrix
- Compared to Backing Track player with reusable patterns identified
This commit is contained in:
Nuwan 2026-01-14 22:19:45 +05:30
parent 7684d7bde1
commit d25bd6262a
3 changed files with 691 additions and 2 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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
<MixdownSelector
mixdowns={availableMixdowns}
selectedMixdownId={selectedMixdownId}
onSelect={handleMixdownChange}
disabled={isOperating || isSyncing}
/>
```
**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
<PlaybackControls
isPlaying={isPlaying}
isPaused={isPaused}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
disabled={isOperating || isSyncing || !!error}
/>
```
**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
<SeekBar
currentPositionMs={currentPositionMs}
durationMs={durationMs}
onSeek={handleSeek}
disabled={isOperating || !durationMs || !!error}
/>
```
**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
<DownloadSyncProgress
downloadState={downloadState}
onRetry={handleRetryDownload}
onCancel={handleCancelDownload}
/>
```
**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
<ErrorBanner
error={error}
errorType={errorType}
onDismiss={clearError}
onRetry={retryOperation}
/>
```
**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
<JamTrackListItem
jamTrack={jamTrack}
onSelect={handleSelect}
downloadState={getDownloadState(jamTrack.id)} // NEW
/>
// Show sync status icon
{downloadState === 'synchronized' && <CheckIcon />}
{downloadState === 'downloading' && <DownloadIcon />}
{downloadState === 'error' && <ErrorIcon />}
```
**Add Mixdown Preview:**
```jsx
<MixdownPreview
jamTrack={jamTrack}
availableMixdowns={jamTrack.mixdowns}
onMixdownSelect={handleMixdownPreview}
/>
```
**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 (
<div className="jamtrack-stems-container">
{jamTrackStems.map(stem => (
<SessionTrack key={stem.track.id}>
<SessionTrackVU mixer={stem.mixer} />
<SessionTrackGain
mixer={stem.mixer}
onVolumeChange={handleVolumeChange}
onPanChange={handlePanChange}
/>
<SessionTrackControls
mixer={stem.mixer}
onMute={handleMute}
onSolo={handleSolo}
/>
</SessionTrack>
))}
</div>
);
};
```
### 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

View File

@ -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';