jam-cloud/jam-ui/src/hooks/useSessionWebSocket.js

310 lines
10 KiB
JavaScript

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useJamServerContext } from '../context/JamServerContext';
import {
addParticipant,
removeParticipant,
updateParticipant,
addUserTrack,
removeUserTrack,
updateUserTrack,
addJamTrack,
removeJamTrack,
addBackingTrack,
removeBackingTrack,
startRecording,
stopRecording,
addRecordedTrack,
setConnectionStatus,
setAvailableMixdowns
} from '../store/features/activeSessionSlice';
import {
updateMediaSummary,
setMetronome
} from '../store/features/mixersSlice';
import {
setBackingTracks,
setJamTracks,
setRecordedTracks,
updateJamTrackState,
setDownloadState
} from '../store/features/mediaSlice';
import {
addMessageFromWebSocket,
incrementUnreadCount
} from '../store/features/sessionChatSlice';
/**
* Helper function to get channel key from WebSocket chat message
*
* Channel keys map Protocol Buffer channel types to Redux state keys:
* - Session messages: Use session_id directly
* - Lesson messages: Use lesson_session_id directly
* - Global messages: Use 'global' literal
*
* @param {Object} message - WebSocket message with channel and ID fields
* @param {string} message.channel - Channel type ('session', 'lesson', or 'global')
* @param {string} [message.session_id] - Session ID (for session channel)
* @param {string} [message.lesson_session_id] - Lesson session ID (for lesson channel)
* @returns {string} Channel key for Redux state
*/
const getChannelKeyFromMessage = (message) => {
if (message.channel === 'session' && message.session_id) {
return message.session_id;
}
if (message.channel === 'lesson' && message.lesson_session_id) {
return message.lesson_session_id;
}
return 'global';
};
/**
* Custom hook to integrate WebSocket messages with Redux state
* Listens to WebSocket events and dispatches appropriate Redux actions
*
* @param {string} sessionId - The active session ID
*/
export const useSessionWebSocket = (sessionId) => {
const dispatch = useDispatch();
const { jamServer, isConnected } = useJamServerContext();
// Get chat state for unread increment logic
const chatState = useSelector((state) => state.sessionChat || { isWindowOpen: false, activeChannel: null });
useEffect(() => {
if (!jamServer || !sessionId) return;
// Update connection status
dispatch(setConnectionStatus(isConnected ? 'connected' : 'disconnected'));
// Define message callbacks that dispatch Redux actions
const callbacks = {
// Participant events
participantJoined: (data) => {
console.log('Participant joined:', data);
dispatch(addParticipant(data.participant));
},
participantLeft: (data) => {
console.log('Participant left:', data);
dispatch(removeParticipant(data.participantId));
},
participantUpdated: (data) => {
console.log('Participant updated:', data);
dispatch(updateParticipant(data.participant));
},
// Track events
trackAdded: (data) => {
console.log('Track added:', data);
dispatch(addUserTrack(data.track));
},
trackRemoved: (data) => {
console.log('Track removed:', data);
dispatch(removeUserTrack(data.trackId));
},
trackUpdated: (data) => {
console.log('Track updated:', data);
dispatch(updateUserTrack(data.track));
},
// Jam Track events
jamTrackAdded: (data) => {
console.log('Jam track added:', data);
dispatch(addJamTrack(data.jamTrack));
},
jamTrackRemoved: (data) => {
console.log('Jam track removed:', data);
dispatch(removeJamTrack(data.jamTrackId));
},
// Backing Track events
backingTrackAdded: (data) => {
console.log('Backing track added:', data);
dispatch(addBackingTrack(data.backingTrack));
},
backingTrackRemoved: (data) => {
console.log('Backing track removed:', data);
dispatch(removeBackingTrack(data.backingTrackId));
},
// Recording events
recordingStarted: (data) => {
console.log('Recording started:', data);
dispatch(startRecording(data.recordingId));
},
recordingStopped: (data) => {
console.log('Recording stopped:', data);
dispatch(stopRecording());
},
recordedTrackReady: (data) => {
console.log('Recorded track ready:', data);
dispatch(addRecordedTrack(data.track));
},
// Phase 5: Mixer and Media events
// NOTE: MIXER_CHANGES message type not currently sent by server
// Media arrays (backingTracks, jamTracks, recordedTracks) are now extracted
// from session REST API response in useSessionModel.js
MIXER_CHANGES: (sessionMixers) => {
console.log('[MIXER_CHANGES] Received (legacy handler):', sessionMixers);
const session = sessionMixers.session;
const mixers = sessionMixers.mixers;
// Update media summary
if (mixers.mediaSummary) {
dispatch(updateMediaSummary(mixers.mediaSummary));
}
// Update media arrays
dispatch(setBackingTracks(mixers.backingTracks || []));
dispatch(setJamTracks(mixers.jamTracks || []));
dispatch(setRecordedTracks(mixers.recordedTracks || []));
dispatch(setMetronome(mixers.metronome || null));
// Phase 5 Plan 2: Handle JamTrack mixdown changes
if (mixers.jamTrackMixdowns) {
dispatch(setAvailableMixdowns(mixers.jamTrackMixdowns));
}
},
JAM_TRACK_CHANGES: (changes) => {
console.log('Jam track changes received:', changes);
dispatch(updateJamTrackState({
isPlaying: changes.jamTrackState?.isPlaying || false,
isPaused: changes.jamTrackState?.isPaused || false,
currentPositionMs: parseInt(changes.jamTrackState?.currentPositionMs || '0', 10),
lastUpdate: Date.now()
}));
},
MIXDOWN_CHANGES: (message) => {
console.log('Mixdown changes received:', message);
if (message.mixdownPackage) {
dispatch(setDownloadState({
currentStep: message.mixdownPackage.current_packaging_step || 0,
totalSteps: message.mixdownPackage.packaging_steps || 0
}));
}
},
/**
* Phase 7 Plan 3: Handle CHAT_MESSAGE from WebSocket
*
* Transforms Protocol Buffer message format to Redux format and handles
* unread count increment based on chat window state.
*
* Unread increment logic:
* - Increment if window is closed
* - Increment if window is open but viewing a different channel
* - Do NOT increment if window is open and viewing the same channel
*
* @param {Object} message - Protocol Buffer formatted chat message
*/
CHAT_MESSAGE: (message) => {
console.log('Chat message received:', message);
// Transform Protocol Buffer format to Redux format
const chatMessage = {
id: message.msg_id,
senderId: message.user_id,
senderName: message.user_name,
message: message.message,
channel: message.channel,
sessionId: message.session_id || null,
lessonSessionId: message.lesson_session_id || null,
createdAt: message.created_at,
purpose: message.purpose || null,
musicNotation: message.music_notation || null,
claimedRecording: message.claimed_recording || null
};
// Add message to Redux state
dispatch(addMessageFromWebSocket(chatMessage));
// Increment unread count if window closed or viewing different channel
const messageChannelKey = getChannelKeyFromMessage(message);
const shouldIncrementUnread = !chatState.isWindowOpen || chatState.activeChannel !== messageChannelKey;
if (shouldIncrementUnread) {
dispatch(incrementUnreadCount({ channel: messageChannelKey }));
}
},
// Handle WebSocket subscription notifications (e.g., mixdown packaging progress)
SUBSCRIPTION_MESSAGE: (header, payload) => {
console.log('[WebSocket] Subscription message received:', { header, payload });
// Parse the payload body (may be JSON string)
let body;
try {
body = typeof payload.body === 'string' ? JSON.parse(payload.body) : payload.body;
} catch (err) {
console.error('[WebSocket] Failed to parse subscription message body:', err);
return;
}
// Handle mixdown packaging progress
if (payload.type === 'mixdown' && body) {
dispatch(setDownloadState({
signing_state: body.signing_state,
packaging_steps: body.packaging_steps || 0,
current_packaging_step: body.current_packaging_step || 0
}));
console.log(`[WebSocket] Mixdown packaging: ${body.signing_state}, step ${body.current_packaging_step}/${body.packaging_steps}`);
// If packaging failed, set error state
if (body.signing_state === 'ERROR' ||
body.signing_state === 'SIGNING_TIMEOUT' ||
body.signing_state === 'QUEUED_TIMEOUT' ||
body.signing_state === 'QUIET_TIMEOUT') {
dispatch(setDownloadState({
state: 'error',
error: {
type: 'download',
message: `Packaging failed: ${body.signing_state}`
}
}));
}
}
},
// Connection events
connectionStatusChanged: (data) => {
console.log('Connection status changed:', data);
dispatch(setConnectionStatus(data.status));
}
};
// Register callbacks with jamServer
// Note: The actual event names will depend on your WebSocket implementation
// Adjust these based on your Protocol Buffer message types
Object.entries(callbacks).forEach(([event, callback]) => {
if (jamServer.on) {
jamServer.on(event, callback);
} else if (jamServer.registerMessageCallback) {
// If using the registerMessageCallback pattern from the legacy code
jamServer.registerMessageCallback(event, callback);
}
});
// Cleanup function to unregister callbacks
return () => {
Object.entries(callbacks).forEach(([event, callback]) => {
if (jamServer.off) {
jamServer.off(event, callback);
} else if (jamServer.unregisterMessageCallback) {
jamServer.unregisterMessageCallback(event, callback);
}
});
};
}, [jamServer, sessionId, isConnected, dispatch, chatState]);
};