605 lines
19 KiB
JavaScript
605 lines
19 KiB
JavaScript
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
|
import { enqueueMixdown } from '../../helpers/rest';
|
|
|
|
// Async thunks for media actions
|
|
export const openBackingTrack = createAsyncThunk(
|
|
'media/openBackingTrack',
|
|
async ({ file, jamClient }, { rejectWithValue }) => {
|
|
try {
|
|
await jamClient.SessionOpenBackingTrackFile(file, false);
|
|
return { file };
|
|
} catch (error) {
|
|
return rejectWithValue(error.message);
|
|
}
|
|
}
|
|
);
|
|
|
|
export const loadJamTrack = createAsyncThunk(
|
|
'media/loadJamTrack',
|
|
async ({ jamTrack, mixdownId = null, autoPlay = false, jamClient }, { 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 })).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 }, { 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
|
|
console.log(`[JamTrack] Enqueueing package ${packageId} for packaging`);
|
|
try {
|
|
await enqueueMixdown(packageId);
|
|
} catch (err) {
|
|
console.error('[JamTrack] Failed to enqueue mixdown:', err);
|
|
throw new Error(`Failed to start packaging: ${err.message}`);
|
|
}
|
|
|
|
// Wait for packaging to complete (signing_state === 'SIGNED')
|
|
// WebSocket handler 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(() => {
|
|
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');
|
|
resolve();
|
|
}
|
|
|
|
// Check for errors
|
|
if (signing_state === 'ERROR' || signing_state === 'SIGNING_TIMEOUT' ||
|
|
signing_state === 'QUEUED_TIMEOUT' || signing_state === 'QUIET_TIMEOUT') {
|
|
clearTimeout(timeout);
|
|
clearInterval(checkInterval);
|
|
reject(new Error(`Packaging failed with state: ${signing_state}`));
|
|
}
|
|
|
|
// Check if download state shows error
|
|
if (downloadStateValue === 'error') {
|
|
clearTimeout(timeout);
|
|
clearInterval(checkInterval);
|
|
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;
|
|
|
|
// 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;
|