From d25bd6262a578ccd0c947c5bd8af5f009659c648 Mon Sep 17 00:00:00 2001 From: Nuwan Date: Wed, 14 Jan 2026 22:19:45 +0530 Subject: [PATCH] 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 --- .claude/settings.local.json | 12 +- .../COMPONENT_DESIGN.md | 679 ++++++++++++++++++ .../client/JKSessionBackingTrackPlayer.js | 2 +- 3 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/04-jamtrack-research-design/COMPONENT_DESIGN.md 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';