diff --git a/jam-ui/src/store/features/mediaSlice.js b/jam-ui/src/store/features/mediaSlice.js index d12ac224e..901406b39 100644 --- a/jam-ui/src/store/features/mediaSlice.js +++ b/jam-ui/src/store/features/mediaSlice.js @@ -15,28 +15,160 @@ export const openBackingTrack = createAsyncThunk( export const loadJamTrack = createAsyncThunk( 'media/loadJamTrack', - async ({ jamTrack, jamClient }, { rejectWithValue }) => { + async ({ jamTrack, mixdownId = null, autoPlay = false, jamClient }, { dispatch, rejectWithValue }) => { try { - // Build fqId for all JamTrack calls (format: {jamTrackId}-{sampleRate}) + // Build fqId const sampleRate = await jamClient.GetSampleRate(); - const sampleRateForFilename = sampleRate === 48 ? '48' : '44'; - const fqId = `${jamTrack.id}-${sampleRateForFilename}`; + const fqId = `${jamTrack.id}-${sampleRate === 48 ? '48' : '44'}`; - // Load JMep data if available (matches MediaContext:163-170 logic) + // 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/load the jamtrack + // Play with fqId (not jamTrack.id) const result = await jamClient.JamTrackPlay(fqId); - if (!result) { - throw new Error('Unable to open JamTrack'); + throw new Error('Unable to play JamTrack'); } - return { jamTrack, result }; + return { jamTrack, fqId, result }; } catch (error) { - return rejectWithValue(error.message); + return rejectWithValue(error.message || error); + } + } +); + +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); + } + } +); + +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); + } + } +); + +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); + } + } +); + +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); + } + } +); + +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); } } ); @@ -212,6 +344,69 @@ export const mediaSlice = createSlice({ state.error = action.payload; }) + // Download jam track + .addCase(downloadJamTrack.pending, (state) => { + state.loading.jamTrack = true; + state.error = null; + }) + .addCase(downloadJamTrack.fulfilled, (state) => { + state.loading.jamTrack = false; + }) + .addCase(downloadJamTrack.rejected, (state, action) => { + state.loading.jamTrack = false; + state.error = action.payload; + }) + + // Check jam track sync + .addCase(checkJamTrackSync.pending, (state) => { + state.downloadState.state = 'checking'; + state.error = null; + }) + .addCase(checkJamTrackSync.fulfilled, (state, action) => { + state.downloadState.state = action.payload.isSynchronized ? 'synchronized' : 'idle'; + }) + .addCase(checkJamTrackSync.rejected, (state, action) => { + state.downloadState.state = 'error'; + state.downloadState.error = { message: action.payload }; + state.error = action.payload; + }) + + // Load JMEP + .addCase(loadJMEP.pending, (state) => { + state.error = null; + }) + .addCase(loadJMEP.fulfilled, (state) => { + // JMEP loaded successfully + }) + .addCase(loadJMEP.rejected, (state, action) => { + state.error = action.payload; + }) + + // Seek jam track + .addCase(seekJamTrack.pending, (state) => { + state.error = null; + }) + .addCase(seekJamTrack.fulfilled, (state, action) => { + if (action.payload.positionMs !== undefined) { + state.jamTrackState.currentPositionMs = action.payload.positionMs; + } + }) + .addCase(seekJamTrack.rejected, (state, action) => { + state.error = action.payload; + }) + + // Close jam track + .addCase(closeJamTrack.pending, (state) => { + state.loading.closing = true; + }) + .addCase(closeJamTrack.fulfilled, (state) => { + state.loading.closing = false; + }) + .addCase(closeJamTrack.rejected, (state, action) => { + state.loading.closing = false; + state.error = action.payload; + }) + // Close media .addCase(closeMedia.pending, (state) => { state.loading.closing = true;