jam-cloud/jam-ui/src/store/features/mediaSlice.js

679 lines
22 KiB
JavaScript

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { enqueueMixdown } from '../../helpers/rest';
// Channel group IDs for track filtering (from globals.js ChannelGroupIds)
const MEDIA_TRACK_GROUP = 6; // ChannelGroupIds.MediaTrackGroup
// Async thunks for media actions
export const openBackingTrack = createAsyncThunk(
'media/openBackingTrack',
async ({ file, jamClient }, { rejectWithValue }) => {
try {
// Open the backing track file via native client
await jamClient.SessionOpenBackingTrackFile(file, false);
// Get track info from native client to populate Redux state
const allTracks = await jamClient.SessionGetAllControlState(true);
// Extract backing track data (same pattern as useTrackHelpers.getBackingTracks)
const backingTracks = [];
for (const track of allTracks) {
if (track.group_id === MEDIA_TRACK_GROUP &&
track.media_type === 'BackingTrack' &&
!track.managed) {
backingTracks.push({
id: track.persisted_track_id,
rid: track.rid,
filename: track.filename,
// Compute shortFilename from full path
shortFilename: track.filename ? track.filename.split('/').pop().split('\\').pop() : 'Audio File'
});
}
}
console.log('[openBackingTrack] Got backing tracks from native client:', backingTracks);
return { file, backingTracks };
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const loadJamTrack = createAsyncThunk(
'media/loadJamTrack',
async ({ jamTrack, mixdownId = null, autoPlay = false, jamClient, jamServer }, { dispatch, rejectWithValue }) => {
try {
// Build fqId
const sampleRate = await jamClient.GetSampleRate();
const fqId = `${jamTrack.id}-${sampleRate === 48 ? '48' : '44'}`;
// 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, jamServer })).unwrap();
}
// Load JMEP if present
if (jamTrack.jmep) {
await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
}
// Play with fqId (not jamTrack.id)
const result = await jamClient.JamTrackPlay(fqId);
if (!result) {
throw new Error('Unable to play JamTrack');
}
return { jamTrack, fqId, result };
} catch (error) {
return rejectWithValue(error.message || error);
}
}
);
export const downloadJamTrack = createAsyncThunk(
'media/downloadJamTrack',
async ({ jamTrack, mixdownId, fqId, jamClient, jamServer }, { dispatch, rejectWithValue, getState }) => {
try {
// Get client sample rate for package selection (pickMyPackage logic)
const rawSampleRate = await jamClient.GetSampleRate();
// Normalize sample rate (jamClient returns float like 44.099998474121094 or 48.0)
// Round to nearest standard rate: 44 or 48 kHz
const sampleRate = rawSampleRate >= 46 ? 48 : 44;
// Use mixdowns from jamTrack object (fetched from REST API)
// jamClient.JamTrackGetMixdowns returns a different structure without packages
const mixdowns = jamTrack.mixdowns;
if (!mixdowns || mixdowns.length === 0) {
throw new Error('No mixdowns available for this JamTrack');
}
// Find the mixdown (use selected mixdownId if provided, else use first)
const mixdown = mixdownId
? mixdowns.find(m => m.id === mixdownId) || mixdowns[0]
: mixdowns[0];
if (!mixdown) {
throw new Error('Selected mixdown not found');
}
if (!mixdown.packages || mixdown.packages.length === 0) {
throw new Error(`Mixdown "${mixdown.name}" has no packages available`);
}
// pickMyPackage logic with fallback strategy
// CRITICAL: Sample rate MUST match - native client cannot play mismatched rates
// Preference order for matching sample rate:
// 1. ogg + jkz encryption (ideal for security)
// 2. ogg + any/no encryption (preferred format)
// 3. any format (mp3, etc.)
let compatiblePackage = null;
// Try 1: Ideal package (ogg, jkz, matching sample rate)
compatiblePackage = mixdown.packages.find(pkg =>
pkg.file_type === 'ogg' &&
pkg.encrypt_type === 'jkz' &&
pkg.sample_rate === sampleRate
);
// Try 2: ogg with any encryption, matching sample rate
if (!compatiblePackage) {
compatiblePackage = mixdown.packages.find(pkg =>
pkg.file_type === 'ogg' &&
pkg.sample_rate === sampleRate
);
}
// Try 3: Any format with matching sample rate
if (!compatiblePackage) {
compatiblePackage = mixdown.packages.find(pkg =>
pkg.sample_rate === sampleRate
);
}
// No fallback to different sample rates - client cannot play mismatched rates
if (!compatiblePackage) {
const availableRates = mixdown.packages.map(p => p.sample_rate).join(', ');
throw new Error(`No package available for sample rate ${sampleRate}kHz. Available rates: ${availableRates}kHz. Try restarting your audio interface or selecting a different sample rate.`);
}
const packageId = compatiblePackage.id;
// ========== PACKAGING PHASE ==========
// Before downloading, package must be created/signed on server
// This matches legacy download_jamtrack.js.coffee behavior
dispatch(setDownloadState({
jamTrackId: jamTrack.id,
mixdownId,
fqId,
packageId,
state: 'packaging',
progress: 0,
packaging_steps: 0,
current_packaging_step: 0,
signing_state: null
}));
// Enqueue mixdown for server-side packaging
// Backend expects mixdown ID + package attributes, not package ID
console.log(`[JamTrack] Enqueueing mixdown ${mixdownId} for packaging (file_type: ${compatiblePackage.file_type}, encrypt_type: ${compatiblePackage.encrypt_type}, sample_rate: ${sampleRate})`);
try {
await enqueueMixdown({
id: mixdownId, // mixdown ID, not package ID
file_type: compatiblePackage.file_type,
encrypt_type: compatiblePackage.encrypt_type,
sample_rate: sampleRate
});
} catch (err) {
console.error('[JamTrack] Failed to enqueue mixdown:', err);
// Handle both Error objects and Response objects (from apiFetch)
const errorMessage = err?.message || err?.statusText || `HTTP ${err?.status}` || 'Unknown error';
throw new Error(`Failed to start packaging: ${errorMessage}`);
}
// Subscribe to WebSocket notifications for packaging progress
// WebSocket gateway will send SUBSCRIPTION_MESSAGE updates
// Handler in useSessionWebSocket.js will dispatch setDownloadState updates
console.log(`[JamTrack] Subscribing to packaging notifications for package ${packageId}`);
if (!jamServer || !jamServer.subscribe) {
throw new Error('WebSocket connection not available');
}
const subscribed = jamServer.subscribe('mixdown', packageId);
if (!subscribed) {
throw new Error('Failed to subscribe to packaging notifications');
}
// Cleanup function for unsubscribing
const unsubscribeFromPackaging = () => {
if (jamServer && jamServer.unsubscribe) {
jamServer.unsubscribe('mixdown', packageId);
}
};
// Wait for packaging to complete (signing_state === 'SIGNED')
// WebSocket notifications will update downloadState as packaging progresses
console.log('[JamTrack] Waiting for package to be signed by server...');
const waitForPackaging = () => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
// Unsubscribe on timeout
unsubscribeFromPackaging();
reject(new Error('Packaging timeout after 60 seconds'));
}, 60000); // 60 second timeout
const checkInterval = setInterval(() => {
const state = getState();
const { signing_state, state: downloadStateValue } = state.media.downloadState;
// Check for success
if (signing_state === 'SIGNED') {
clearTimeout(timeout);
clearInterval(checkInterval);
console.log('[JamTrack] Package signed successfully, proceeding to download');
// Unsubscribe on success
unsubscribeFromPackaging();
resolve();
}
// Check for errors
if (signing_state === 'ERROR' || signing_state === 'SIGNING_TIMEOUT' ||
signing_state === 'QUEUED_TIMEOUT' || signing_state === 'QUIET_TIMEOUT') {
clearTimeout(timeout);
clearInterval(checkInterval);
// Unsubscribe on error
unsubscribeFromPackaging();
reject(new Error(`Packaging failed with state: ${signing_state}`));
}
// Check if download state shows error
if (downloadStateValue === 'error') {
clearTimeout(timeout);
clearInterval(checkInterval);
// Unsubscribe on error
unsubscribeFromPackaging();
reject(new Error('Packaging failed'));
}
}, 500); // Check every 500ms
});
};
await waitForPackaging();
// ========== DOWNLOAD PHASE ==========
// Package is now signed and ready to download
dispatch(setDownloadState({
state: 'downloading',
progress: 0
}));
// 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);
}
}
);
export const closeMedia = createAsyncThunk(
'media/closeMedia',
async ({ force = false, jamClient }, { rejectWithValue }) => {
try {
await jamClient.SessionCloseMedia(force);
return null;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const initialState = {
// Media arrays (resolved/enriched data from mixer system)
// Format matches useMixerHelper resolved data structures
backingTracks: [], // [{ isOpener, shortFilename, instrumentIcon, photoUrl, showLoop, track, mixers }]
jamTracks: [], // [{ name, trackName, part, isOpener, instrumentIcon, track, mixers }]
recordedTracks: [], // [{ recordingName, isOpener, userName, instrumentIcon, track, mixers }]
// JamTrack state (real-time playback state from native client)
jamTrackState: {
isPlaying: false,
isPaused: false,
currentPositionMs: 0,
durationMs: 0,
selectedMixdownId: null,
playbackMode: null, // 'master' | 'custom-mix' | 'stem'
lastUpdate: null
},
// Download/sync state machine
downloadState: {
jamTrackId: null,
mixdownId: null,
fqId: null,
packageId: null, // Package being packaged/downloaded
state: 'idle', // 'idle' | 'checking' | 'packaging' | 'downloading' | 'keying' | 'synchronized' | 'error'
progress: 0,
currentStep: 0,
totalSteps: 0,
// Packaging-specific fields (from legacy JamTrackStore)
packaging_steps: 0, // Total packaging steps
current_packaging_step: 0, // Current step in packaging
signing_state: null, // 'SIGNING_TIMEOUT' | 'QUEUED_TIMEOUT' | 'QUIET_TIMEOUT' | 'ERROR' | 'SIGNED' | null
error: null
},
// Loading states for async operations
loading: {
backingTrack: false,
jamTrack: false,
closing: false
},
error: null
};
export const mediaSlice = createSlice({
name: 'media',
initialState,
reducers: {
// Set resolved media data (called from useMixerHelper after processing)
setBackingTracks: (state, action) => {
state.backingTracks = action.payload;
},
setJamTracks: (state, action) => {
state.jamTracks = action.payload;
},
setRecordedTracks: (state, action) => {
state.recordedTracks = action.payload;
},
// JamTrack state updates (from WebSocket JAM_TRACK_CHANGES messages)
updateJamTrackState: (state, action) => {
state.jamTrackState = { ...state.jamTrackState, ...action.payload };
},
setJamTrackState: (state, action) => {
state.jamTrackState = action.payload;
},
clearJamTrackState: (state) => {
state.jamTrackState = {
isPlaying: false,
isPaused: false,
currentPositionMs: 0,
durationMs: 0,
selectedMixdownId: null,
playbackMode: null,
lastUpdate: null
};
},
// Download state management
setDownloadState: (state, action) => {
state.downloadState = { ...state.downloadState, ...action.payload };
},
clearDownloadState: (state) => {
state.downloadState = {
jamTrackId: null,
mixdownId: null,
fqId: null,
packageId: null,
state: 'idle',
progress: 0,
currentStep: 0,
totalSteps: 0,
packaging_steps: 0,
current_packaging_step: 0,
signing_state: null,
error: null
};
},
// Clear all media (called on session end or media close)
clearAllMedia: (state) => {
state.backingTracks = [];
state.jamTracks = [];
state.recordedTracks = [];
state.jamTrackState = {
isPlaying: false,
isPaused: false,
currentPositionMs: 0,
durationMs: 0,
selectedMixdownId: null,
playbackMode: null,
lastUpdate: null
};
state.downloadState = {
jamTrackId: null,
mixdownId: null,
fqId: null,
state: 'idle',
progress: 0,
currentStep: 0,
totalSteps: 0,
error: null
};
state.error = null;
}
},
extraReducers: (builder) => {
builder
// Open backing track
.addCase(openBackingTrack.pending, (state) => {
state.loading.backingTrack = true;
state.error = null;
})
.addCase(openBackingTrack.fulfilled, (state) => {
state.loading.backingTrack = false;
// Note: backingTracks array updated by MIXER_CHANGES WebSocket message
// which triggers useMixerHelper to resolve and dispatch setBackingTracks
})
.addCase(openBackingTrack.rejected, (state, action) => {
state.loading.backingTrack = false;
state.error = action.payload;
})
// Load jam track
.addCase(loadJamTrack.pending, (state) => {
state.loading.jamTrack = true;
state.downloadState.state = 'checking';
state.error = null;
})
.addCase(loadJamTrack.fulfilled, (state) => {
state.loading.jamTrack = false;
state.downloadState.state = 'synchronized';
// Note: jamTracks array updated by MIXER_CHANGES WebSocket message
})
.addCase(loadJamTrack.rejected, (state, action) => {
state.loading.jamTrack = false;
state.downloadState.state = 'error';
state.downloadState.error = { message: action.payload };
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;
})
.addCase(closeMedia.fulfilled, (state) => {
state.loading.closing = false;
// Clear all media data
state.backingTracks = [];
state.jamTracks = [];
state.recordedTracks = [];
state.jamTrackState = {
isPlaying: false,
isPaused: false,
currentPositionMs: 0,
durationMs: 0,
selectedMixdownId: null,
playbackMode: null,
lastUpdate: null
};
state.downloadState = {
jamTrackId: null,
mixdownId: null,
fqId: null,
state: 'idle',
progress: 0,
currentStep: 0,
totalSteps: 0,
error: null
};
})
.addCase(closeMedia.rejected, (state, action) => {
state.loading.closing = false;
state.error = action.payload;
});
}
});
export const {
setBackingTracks,
setJamTracks,
setRecordedTracks,
updateJamTrackState,
setJamTrackState,
clearJamTrackState,
setDownloadState,
clearDownloadState,
clearAllMedia
} = mediaSlice.actions;
export default mediaSlice.reducer;
// Cleanup function for JamTrack download callbacks
// Called on component unmount to prevent memory leaks from orphaned window globals
// Pattern matches useRecordingHelpers.js cleanup approach
export const cleanupJamTrackCallbacks = () => {
if (typeof window !== 'undefined') {
delete window.jamTrackDownloadProgress;
delete window.jamTrackDownloadSuccess;
delete window.jamTrackDownloadFail;
}
};
// Selectors
export const selectBackingTracks = (state) => state.media.backingTracks;
export const selectJamTracks = (state) => state.media.jamTracks;
export const selectRecordedTracks = (state) => state.media.recordedTracks;
export const selectJamTrackState = (state) => state.media.jamTrackState;
export const selectDownloadState = (state) => state.media.downloadState;
export const selectMediaLoading = (state) => state.media.loading;
export const selectMediaError = (state) => state.media.error;