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:
parent
7684d7bde1
commit
d25bd6262a
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue