jam-cloud/jam-ui/src/components/client/JKSessionScreen.js

1740 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// jam-ui/src/components/client/JKSessionScreen.js
import React, { useEffect, useRef, useState, memo, useMemo, useCallback } from 'react'
import { useParams, useHistory } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
//import useJamServer, { ConnectionStatus } from '../../hooks/useJamServer'
import useGearUtils from '../../hooks/useGearUtils'
import useSessionUtils from '../../hooks/useSessionUtils.js';
import useSessionModel from '../../hooks/useSessionModel.js';
import useSessionHelper from '../../hooks/useSessionHelper.js';
import useRecordingHelpers from '../../hooks/useRecordingHelpers.js';
import useMixerStore from '../../hooks/useMixerStore.js';
import { useJamServerContext } from '../../context/JamServerContext.js';
import { useGlobalContext } from '../../context/GlobalContext.js';
import { useJamKazamApp } from '../../context/JamKazamAppContext.js';
import { useMixersContext } from '../../context/MixersContext.js';
import { useAuth } from '../../context/UserAuth';
import useMediaActions from '../../hooks/useMediaActions';
import { dkeys } from '../../helpers/utils.js';
import { getSessionHistory, getSession, joinSession as joinSessionRest, updateSessionSettings, getFriends, startRecording, stopRecording, submitSessionFeedback, getVideoConferencingRoomUrl, getJamTrack, closeJamTrack, openMetronome } from '../../helpers/rest';
import { syncTracksToServer } from '../../services/trackSyncService';
// Redux imports
import { openModal, closeModal, toggleModal, selectModal } from '../../store/features/sessionUISlice';
import { selectMediaSummary, selectMetronomeTrackMixers, selectMixersReady } from '../../store/features/mixersSlice';
import {
fetchActiveSession,
joinActiveSession,
leaveActiveSession,
setGuardsPassed,
setUserTracks,
setConnectionStatus,
setSessionId,
updateSessionData,
setSelectedJamTrack,
setJamTrackStems,
setBackingTrackData,
clearBackingTrackData,
setJamTrackData,
clearJamTrackData,
clearSession,
selectActiveSession,
selectJoinStatus,
selectHasJoined,
selectGuardsPassed,
selectUserTracks,
selectShowConnectionAlert,
selectSessionId,
selectInSession,
selectSelectedJamTrack,
selectJamTrackStems,
selectBackingTrackData,
selectJamTrackData
} from '../../store/features/activeSessionSlice';
import { addMessageFromWebSocket, uploadAttachment, selectIsUploading, selectUploadError, selectUploadFileName, selectUploadStatus, clearUploadError, fetchChatHistory } from '../../store/features/sessionChatSlice';
import { validateFile } from '../../services/attachmentValidation';
import { CLIENT_ROLE, RECORD_TYPE_AUDIO, RECORD_TYPE_BOTH } from '../../helpers/globals';
import { MessageType } from '../../helpers/MessageFactory.js';
import { Alert, Col, Row, Button, Card, CardBody, Modal, ModalHeader, ModalBody, ModalFooter, CardHeader, Badge, UncontrolledTooltip, Spinner } from 'reactstrap';
import FalconCardHeader from '../common/FalconCardHeader';
import SessionTrackVU from './SessionTrackVU.js';
import JKSessionMyTrack from './JKSessionMyTrack.js';
import JKSessionAudioInputs from './JKSessionAudioInputs.js';
import JKSessionRemoteTracks from './JKSessionRemoteTracks.js';
import JKSessionSettingsModal from './JKSessionSettingsModal.js';
import JKSessionInviteModal from './JKSessionInviteModal.js';
import JKSessionVolumeModal from './JKSessionVolumeModal.js';
import JKSessionRecordingModal from './JKSessionRecordingModal.js';
import JKSessionLeaveModal from './JKSessionLeaveModal.js';
import JKSessionJamTrackModal from './JKSessionJamTrackModal.js';
import JKSessionJamTrackStems from './JKSessionJamTrackStems.js';
import JKSessionOpenMenu from './JKSessionOpenMenu.js';
import WindowPortal from '../common/WindowPortal.js';
import JKSessionBackingTrackPlayer from './JKSessionBackingTrackPlayer.js';
import JKSessionJamTrackPlayer from './JKSessionJamTrackPlayer.js';
import JKSessionBackingTrack from './JKSessionBackingTrack.js';
import JKSessionMetronome from './JKSessionMetronome.js';
import JKSessionMetronomePlayer from './JKSessionMetronomePlayer.js';
import JKSessionChatWindow from './JKSessionChatWindow.js';
import JKSessionChatButton from './JKSessionChatButton.js';
import JKPopupMediaControls from '../popups/JKPopupMediaControls.js';
import { SESSION_PRIVACY_MAP } from '../../helpers/globals.js';
import { toast } from 'react-toastify';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
//icon imports
import gearIcon from '../../assets/img/client/gear.svg';
import inviteIcon from '../../assets/img/client/invite.svg';
import volumeIcon from '../../assets/img/client/volume.svg';
import videoIcon from '../../assets/img/client/video.svg';
import recordIcon from '../../assets/img/client/record.svg';
import broadcastIcon from '../../assets/img/client/broadcast.svg';
import openIcon from '../../assets/img/client/open.svg';
import chatIcon from '../../assets/img/client/chat.svg';
import attachIcon from '../../assets/img/client/attach.svg';
import resyncIcon from '../../assets/img/client/resync.svg';
const JKSessionScreen = () => {
const logger = console; // Replace with another logging mechanism if needed
const dispatch = useDispatch();
const app = useJamKazamApp();
const { currentUser } = useAuth();
const {
guardAgainstInvalidConfiguration,
guardAgainstActiveProfileMissing,
guardAgainstSinglePlayerProfile,
resyncAudio,
} = useGearUtils();
const { initialize: initializeMixer, onSessionChange } = useMixerStore();
const mixerHelper = useMixersContext();
const { isConnected,
ConnectionStatus,
connectionStatus,
reconnectAttempts,
lastError,
jamClient,
server,
registerMessageCallback,
unregisterMessageCallback } = useJamServerContext();
// Phase 4: Replace CurrentSessionContext with Redux
const currentSession = useSelector(selectActiveSession);
const sessionId = useSelector(selectSessionId);
const inSessionFlag = useSelector(selectInSession);
// Create ref for line 225, 365 compatibility
const currentSessionIdRef = useRef(sessionId);
useEffect(() => {
currentSessionIdRef.current = sessionId;
}, [sessionId]);
const inSession = useCallback(() => inSessionFlag, [inSessionFlag]);
const { globalObject, metronomeState, updateMetronomeState, closeMetronome, resetMetronome } = useGlobalContext();
const { getCurrentRecordingState, reset: resetRecordingState, currentlyRecording } = useRecordingHelpers();
const { SessionPageEnter } = useSessionUtils();
// Redux media state and actions
const mediaSummary = useSelector(selectMediaSummary);
const metronomeTrackMixers = useSelector(selectMetronomeTrackMixers);
const { openBackingTrack, openMetronome: openMetronomeAction, loadJamTrack, closeMedia } = useMediaActions();
// Use the session model hook
const sessionModel = useSessionModel(app, server, null); // sessionScreen is null for now
const sessionHelper = useSessionHelper();
const { id: sessionIdFromUrl } = useParams(); // Renamed to avoid conflict with Redux sessionId
const history = useHistory();
// Redux session lifecycle state
const activeSession = useSelector(selectActiveSession);
const joinStatus = useSelector(selectJoinStatus);
const hasJoined = useSelector(selectHasJoined);
const sessionGuardsPassed = useSelector(selectGuardsPassed);
const userTracks = useSelector(selectUserTracks);
const mixersReady = useSelector(selectMixersReady);
const showConnectionAlert = useSelector(selectShowConnectionAlert);
const reduxSessionId = useSelector(selectSessionId);
// Non-lifecycle state (keeping as local for now)
const [requestingSessionRefresh, setRequestingSessionRefresh] = useState(false);
const [pendingSessionRefresh, setPendingSessionRefresh] = useState(false);
// Redux modal state
const showSettingsModal = useSelector(selectModal('settings'));
const showInviteModal = useSelector(selectModal('invite'));
const showVolumeModal = useSelector(selectModal('volume'));
const showRecordingModal = useSelector(selectModal('recording'));
const showLeaveModal = useSelector(selectModal('leave'));
const showJamTrackModal = useSelector(selectModal('jamTrack'));
const showBackingTrackPopup = useSelector(selectModal('backingTrack'));
const showMediaControlsPopup = useSelector(selectModal('mediaControls'));
// Non-modal state for settings modal
const [settingsLoading, setSettingsLoading] = useState(false);
// Non-modal state for invite modal
const [friends, setFriends] = useState([]);
const [sessionInvitees, setSessionInvitees] = useState([]);
const [inviteLoading, setInviteLoading] = useState(false);
// Non-modal state for volume modal
const [volumeLevel, setVolumeLevel] = useState(100);
// Non-modal state for leave modal
const [leaveRating, setLeaveRating] = useState(null); // null, 'thumbsUp', 'thumbsDown'
const [leaveComments, setLeaveComments] = useState('');
const [leaveLoading, setLeaveLoading] = useState(false);
//state for video button
const [videoLoading, setVideoLoading] = useState(false);
// State for resync button
const [resyncLoading, setResyncLoading] = useState(false);
// Redux backing track state (modal visibility and data)
const backingTrackData = useSelector(selectBackingTrackData);
const showBackingTrackPlayer = Boolean(backingTrackData);
// Redux upload state for attachments
const isUploading = useSelector(selectIsUploading);
const uploadError = useSelector(selectUploadError);
const uploadFileName = useSelector(selectUploadFileName);
const uploadStatus = useSelector(selectUploadStatus);
const prevUploadStatusRef = useRef(uploadStatus);
// File input ref for attach button
const attachFileInputRef = useRef(null);
// Stable callback for backing track popup close
const handleBackingTrackClose = useCallback(() => {
console.log('JKSessionScreen: Backing Track Popup closing');
dispatch(closeModal('backingTrack'));
dispatch(clearBackingTrackData());
}, [dispatch]);
// Stable callback for backing track close in main screen
const handleBackingTrackMainClose = useCallback(async () => {
console.log('JKSessionScreen: Backing Track main screen close requested');
await closeMedia();
// Also clear the backing track popup data
dispatch(clearBackingTrackData());
dispatch(closeModal('backingTrack'));
}, [closeMedia, dispatch]);
// Redux JamTrack player state (modal visibility and data)
const jamTrackData = useSelector(selectJamTrackData);
const showJamTrackPlayer = Boolean(jamTrackData);
// Redux jam track state
const selectedJamTrack = useSelector(selectSelectedJamTrack);
const jamTrackStems = useSelector(selectJamTrackStems);
const jamTrackDownloadState = useSelector(state => state.media.downloadState);
// JamTrack close handler (used by both player and session screen)
const handleJamTrackClose = useCallback(async () => {
console.log('Closing jam track');
try {
// Call the close jam track API
await closeJamTrack({ id: currentSession.id });
// Clear the selected jam track and stems (session screen)
dispatch(setSelectedJamTrack(null));
dispatch(setJamTrackStems([]));
// Clear the player popup data
dispatch(clearJamTrackData());
toast.success('JamTrack closed successfully');
} catch (error) {
console.error('Error closing jam track:', error);
toast.error('Failed to close JamTrack');
}
}, [currentSession, dispatch]);
// Metronome close handler (used by both popup and session track)
const handleMetronomeClose = useCallback(async () => {
console.log('JKSessionScreen: Closing metronome');
try {
// Call jamClient to close metronome
await jamClient.SessionCloseMetronome();
// Update local metronome state
if (closeMetronome) {
closeMetronome();
}
toast.success('Metronome closed successfully');
} catch (error) {
console.error('Error closing metronome:', error);
toast.error('Failed to close metronome');
}
}, [jamClient, closeMetronome]);
// Stable callback for JamTrack player popup close (WindowPortal X button or ESC)
const handleJamTrackPlayerClose = useCallback(async () => {
console.log('JKSessionScreen: JamTrack Player Popup closing');
// Call the full close handler to clean up everything
await handleJamTrackClose();
}, [handleJamTrackClose]);
// State for media controls popup (modal visibility now in Redux)
const [mediaControlsOpened, setMediaControlsOpened] = useState(false);
const [popupGuard, setPopupGuard] = useState(false); // Hard guard against infinite loops
// Store references to registered message callbacks for cleanup
const [registeredCallbacks, setRegisteredCallbacks] = useState([]);
// Function to unregister message callbacks
const unregisterMessageCallbacks = useCallback(() => {
registeredCallbacks.forEach(({ type, callback }) => {
unregisterMessageCallback(type, callback);
});
setRegisteredCallbacks([]);
}, [registeredCallbacks, unregisterMessageCallback]);
// Fetch session data when URL sessionId changes
useEffect(() => {
if (sessionIdFromUrl && sessionIdFromUrl !== sessionId) {
console.log('Fetching active session:', sessionIdFromUrl);
dispatch(fetchActiveSession(sessionIdFromUrl));
}
}, [sessionIdFromUrl, sessionId, dispatch]);
// Setup global alert callback handler for backend mixer changes
useEffect(() => {
if (!window.JK) window.JK = {};
// Alert callback handler - routes backend alerts to appropriate handlers
const alertCallbackHandler = (type, text) => {
console.log('[AlertCallback] type:', type, 'text:', text);
// BACKEND_MIXER_CHANGE = 2
if (type === 2) {
console.log('[AlertCallback] BACKEND_MIXER_CHANGE:', text);
if (sessionModel && sessionModel.onBackendMixerChanged) {
sessionModel.onBackendMixerChanged(type, text);
}
}
// Alert code 40: Audio profile issues (common with built-in audio)
if (type === 40) {
console.warn('[AlertCallback] Audio profile alert (40):', text);
}
// Handle other alert types as needed
};
// Register under both names (native client may use either)
window.JK.HandleAlertCallback = alertCallbackHandler;
window.JK.AlertCallback = alertCallbackHandler;
return () => {
if (window.JK) {
delete window.JK.HandleAlertCallback;
}
};
}, [sessionModel]);
useEffect(() => {
if (!isConnected || !jamClient) return;
console.debug("-DEBUG- JKSessionScreen: isConnected changed to true");
guardOnJoinSession();
}, [isConnected, jamClient]); // Added jamClient to dependencies for stability
const guardOnJoinSession = async () => {
console.log("-DEBUG- JKSessionScreen: guardOnJoinSession")
try {
const musicSessionResp = await getSessionHistory(sessionIdFromUrl);
const musicSession = await musicSessionResp.json();
logger.log("-DEBUG- JKSessionScreen: fetched session history: ", musicSession);
dispatch(setSessionId(musicSession.id)); // Phase 4: dispatch to Redux
const musicianAccessOnJoin = musicSession.musician_access;
const shouldVerifyNetwork = musicSession.musician_access;
const clientRole = await jamClient.getClientParentChildRole();
logger.log("-DEBUG- JKSessionScreen: musicianAccessOnJoin when joining session: " + musicianAccessOnJoin);
logger.log("-DEBUG- JKSessionScreen: clientRole when joining session: " + clientRole);
logger.log("-DEBUG- JKSessionScreen: currentSessionId when joining session: " + currentSessionIdRef.current);
if (clientRole === CLIENT_ROLE.CHILD) {
logger.debug("-DEBUG- JKSessionScreen: client is configured to act as child. skipping all checks. assuming 0 tracks");
dispatch(setUserTracks([]));
//skipping all checks. assuming 0 tracks
await joinSession();
return;
}
try {
await guardAgainstInvalidConfiguration(app, shouldVerifyNetwork);
const result = await SessionPageEnter();
logger.log("-DEBUG- JKSessionScreen: SessionPageEnter result: ", result);
try {
await guardAgainstActiveProfileMissing(app, result);
logger.log("-DEBUG- JKSessionScreen: user has an active profile");
try {
const tracks = await sessionModel.waitForSessionPageEnterDone();
dispatch(setUserTracks(tracks));
logger.log("-DEBUG- JKSessionScreen: userTracks: ", tracks);
try {
await ensureAppropriateProfile(musicianAccessOnJoin)
logger.log("-DEBUG- JKSessionScreen: user has passed all session guards")
dispatch(setGuardsPassed(true))
} catch (error) {
logger.error("-DEBUG- JKSessionScreen: User profile is not appropriate for session:", error);
// If error doesn't control navigation, show alert (legacy behavior shows dialog)
if (!error?.controlled_location) {
logger.debug("-DEBUG- JKSessionScreen: Profile guard failed, showing alert");
// TODO: Replace with proper JKSessionProfileDialog component
alert(
'Your current audio profile is not suitable for multi-user sessions.\n\n' +
'Please configure a proper audio interface in your audio settings, ' +
'or create a private session for solo practice.'
);
await sessionModel.handleLeaveSession();
history.push('/'); // Redirect to dashboard
return; // Don't continue with session join
}
// If controlled_location is true, the error handler already redirected
}
} catch (error) {
logger.error("-DEBUG- JKSessionScreen: Error: waiting for session page enter to complete:", error);
if (error === "timeout") {
//TODO: show some error
} else if (error === 'session_over') {
// do nothing; session ended before we got the user track info. just bail
logger.debug("-DEBUG- JKSessionScreen: Error:: session is over; bailing");
} else {
//TODO: show some error
}
await sessionModel.handleLeaveSession(); //TODO: handle redirection
}
} catch (error) {
// Active profile is missing, redirect to home or if the error has a location, redirect there
logger.error("-DEBUG- JKSessionScreen: Error: Active profile is missing or invalid:", error);
}
} catch (error) {
// Invalid configuration, redirect to home
await sessionModel.handleLeaveSession(); //TODO: handle redirection
logger.error("-DEBUG- JKSessionScreen: Error: Invalid configuration:", error);
}
} catch (error) {
logger.error("-DEBUG- JKSessionScreen: Error: Error fetching session history:", error);
//TODO: Show some error
}
};
useEffect(() => {
if (!sessionGuardsPassed || userTracks.length === 0 || hasJoined) { return }
joinSession();
}, [sessionGuardsPassed, userTracks, hasJoined])
// Track sync: Sync tracks to server when session joined (3-call pattern matching legacy)
// IMPORTANT: Wait for mixers to be ready before syncing to avoid race condition with mixer initialization
useEffect(() => {
if (!hasJoined || !sessionId || !server?.clientId || !mixersReady) {
return;
}
console.log('[Track Sync] Mixers ready, scheduling track sync calls');
// First sync: Initial setup (~1s after join)
const timer1 = setTimeout(() => {
dispatch(syncTracksToServer(sessionId, server.clientId));
}, 1000);
// Second sync: Refinement (~1.4s after join)
const timer2 = setTimeout(() => {
dispatch(syncTracksToServer(sessionId, server.clientId));
}, 1400);
// Third sync: Final config (~6s after join)
const timer3 = setTimeout(() => {
dispatch(syncTracksToServer(sessionId, server.clientId));
}, 6000);
// Cleanup timers on unmount or if hasJoined/sessionId changes
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
};
// Note: server intentionally NOT in deps to avoid re-running on server reference changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasJoined, sessionId, mixersReady, dispatch])
// Fetch chat history when session joins to populate unread badge
// This ensures unread count persists across page reloads
useEffect(() => {
if (!hasJoined || !sessionId) {
return;
}
dispatch(fetchChatHistory({
channel: sessionId,
sessionId: sessionId
}));
}, [hasJoined, sessionId, dispatch]);
const joinSession = async () => {
await jamClient.SetVURefreshRate(150);
await jamClient.SessionRegisterCallback("JK.HandleBridgeCallback2");
await jamClient.SessionSetAlertCallback("JK.HandleAlertCallback");
await jamClient.RegisterRecordingCallbacks("JK.HandleRecordingStartResult", "JK.HandleRecordingStopResult", "JK.HandleRecordingStarted", "JK.HandleRecordingStopped", "JK.HandleRecordingAborted");
await jamClient.SessionSetConnectionStatusRefreshRate(1000);
let clientRole = await jamClient.getClientParentChildRole();
const parentClientId = await jamClient.getParentClientId();
console.debug('role when joining session: ' + clientRole + ', parentClientId: ' + parentClientId);
if (clientRole === 0) {
clientRole = 'child';
} else if (clientRole === 1) {
clientRole = 'parent';
}
if ((clientRole === '') || !clientRole) {
clientRole = null;
}
// subscribe to events from the recording model
//this.recordingRegistration(); //TODO: implement recording registration
// tell the server we want to join
//const clientId = await jamClient.clientID();
const clientId = server.clientId;
console.log("joining session " + sessionId + " as client " + JSON.stringify(clientId) + " with role " + clientRole + " and parent client " + parentClientId);
const latency = await jamClient.FTUEGetExpectedLatency().latency
console.log("joinSession parameters: ", {
client_id: clientId,
ip_address: server.publicIP,
as_musician: true,
tracks: userTracks,
session_id: sessionId,
client_role: clientRole,
parent_client_id: parentClientId,
audio_latency: latency,
});
joinSessionRest({
client_id: clientId,
ip_address: server.publicIP,
as_musician: true,
tracks: userTracks,
session_id: sessionId,
client_role: clientRole,
parent_client_id: parentClientId,
audio_latency: latency,
}).then(async (response) => {
console.debug("joinSessionRest response received", response.errors);
if (response.errors) {
throw new Error("Unable to join session: " + JSON.stringify(response.errors));
} else {
const data = await response.json();
console.debug("join session response xxx: ", data);
// Update Redux state - user has successfully joined
dispatch(joinActiveSession.fulfilled(data, '', { sessionId, options: {} }));
if (!inSession()) {
// the user has left the session before they got joined. We need to issue a leave again to the server to make sure they are out
logger.debug("user left before fully joined to session. telling server again that they have left");
sessionModel.leaveSessionRest();
}
sessionModel.updateSessionInfo(data, true);
dispatch(updateSessionData(data)); // Phase 4: dispatch to Redux
//TODO: revist this logic later
// on temporary disconnect scenarios, a user may already be in a session when they enter this path
// so we avoid double counting
// if (!this.alreadyInSession()) {
// if (this.participants().length === 1) {
// context.JK.GA.trackSessionMusicians(context.JK.GA.SessionCreationTypes.create);
// } else {
// context.JK.GA.trackSessionMusicians(context.JK.GA.SessionCreationTypes.join);
// }
// }
// this.recordingModel.reset(this.currentSessionId); //TODO: implement recording model
const joinSessionMsg = {
sessionID: currentSession.id,
music_session_id_int: response.music_session_id_int
};
await jamClient.JoinSession(joinSessionMsg);
//@refreshCurrentSession(true);
// Chat message handler - receives messages from WebSocket and dispatches to Redux
// Callback signature: (fullMessage, payload) where payload contains the chat_message data
const handleChatMessage = (fullMessage, payload) => {
// Transform Protocol Buffer format to Redux format
// payload contains: {sender_name, sender_id, msg, msg_id, created_at, channel, ...}
const message = {
id: payload.msg_id,
senderId: payload.sender_id,
senderName: payload.sender_name,
message: payload.msg,
createdAt: payload.created_at,
channel: payload.channel || 'session',
sessionId: currentSession.id,
// Attachment fields (null/undefined if not an attachment)
purpose: payload.purpose, // 'Notation File', 'Audio File', or undefined
attachmentId: payload.attachment_id, // MusicNotation UUID
attachmentType: payload.attachment_type, // 'notation' or 'audio'
attachmentName: payload.attachment_name, // filename
attachmentSize: payload.attachment_size // file size in bytes (may be null)
};
dispatch(addMessageFromWebSocket(message));
};
// Register message callbacks and store references for cleanup
const callbacksToRegister = [
{ type: MessageType.SESSION_JOIN, callback: trackChanges },
{ type: MessageType.SESSION_DEPART, callback: trackChanges },
{ type: MessageType.TRACKS_CHANGED, callback: trackChanges },
{ type: MessageType.HEARTBEAT_ACK, callback: trackChanges },
{ type: MessageType.CHAT_MESSAGE, callback: handleChatMessage }
];
callbacksToRegister.forEach(({ type, callback }) => {
registerMessageCallback(type, callback);
});
// Store registered callbacks for cleanup
setRegisteredCallbacks(callbacksToRegister);
//TODO: revist the logic in following commented section
//if (document) { $(document).trigger(EVENTS.SESSION_STARTED, { session: { id: this.currentSessionId, lesson_session: response.lesson_session } }); }
// this.handleAutoOpenJamTrack();
// this.watchBackendStats();
// ConfigureTracksActions.reset(true);
// this.delayEnableVst();
// logger.debug("completed session join")
}
}).catch((xhr) => {
console.error("joinSessionRest error: ", xhr);
let leaveBehavior;
sessionModel.updateCurrentSession(null);
if (xhr.status === 404) {
// we tried to join the session, but it is already gone. kick user back to join session screen
} else if (xhr.status === 422) {
//console.error("unable to join session - 422 error");
// const response = JSON.parse(xhr.responseText);
// if (response["errors"] && response["errors"]["tracks"] && (response["errors"]["tracks"][0] === "Please select at least one track")) {
// // You will need to reconfigure your audio device. show an alert
// } else if (response["errors"] && response["errors"]["music_session"] && (response["errors"]["music_session"][0] == ["is currently recording"])) {
// //The session is currently recording. You can not join a session that is recording. show an alert
// } else if (response["errors"] && response["errors"]["remaining_session_play_time"]) {
// //user has no available playtime. upgrade
// } else if (response["errors"] && response["errors"]["remaining_month_play_time"]) {
// //user has no available playtime. upgrade
// } else {
// // unknown 422 error. alert unable to join sessio
// }
} else {
// unable to join session
}
})
}
useEffect(() => {
if (!isConnected || !hasJoined) return;
onSessionChange(sessionHelper);
}, [isConnected, hasJoined, sessionHelper.id()]);
const ensureAppropriateProfile = async (musicianAccess) => {
return new Promise(async function (resolve, reject) {
if (musicianAccess) {
try {
await guardAgainstSinglePlayerProfile(app);
resolve();
} catch (error) {
reject(error)
}
} else {
resolve();
}
})
};
// Use sessionModel functions
const trackChanges = sessionModel.trackChanges;
const refreshCurrentSession = sessionModel.refreshCurrentSession;
const updateSession = sessionModel.updateSession;
const musicianAccess = useMemo(() => {
if (!currentSession) return null;
return sessionModel.getMusicianAccess();
}, [currentSession]);
// Memoize chat object to prevent unnecessary re-renders
const chat = useMemo(() => {
if (!mixerHelper.chatMixer) return null;
return {
mixers: mixerHelper.chatMixer,
mode: mixerHelper.mixMode,
photoUrl: mixerHelper.myTracks.length > 0 ? mixerHelper.myTracks[0].photoUrl : '',
name: 'Chat'
};
}, [mixerHelper.chatMixer, mixerHelper.mixMode, mixerHelper.myTracks]);
// useEffect(() => {
// if (!isConnected) return;
// // validate session by fetching the session from the server
// fetchSession();
// }, [isConnected]);
// const fetchSession = async () => {
// const session = await getSession(sessionId);
// if (session) {
// setSessionState(prevState => ({
// ...prevState,
// ...session
// }));
// } else {
// logger.error("Invalid session ID or unable to fetch session");
// //TODO: Handle invalid session (e.g., redirect or show error)
// }
// };
// Monitor connection status changes
useEffect(() => {
if (connectionStatus === ConnectionStatus.DISCONNECTED ||
connectionStatus === ConnectionStatus.ERROR) {
dispatch(setConnectionStatus('disconnected'));
} else if (connectionStatus === ConnectionStatus.CONNECTED) {
dispatch(setConnectionStatus('connected'));
} else if (connectionStatus === ConnectionStatus.RECONNECTING) {
dispatch(setConnectionStatus('reconnecting'));
}
}, [connectionStatus, dispatch]);
// Handlers for recording and playback
// const handleStartRecording = () => {
// jamClient.StartRecording({ recordingId: `rec_${Date.now()}` });
// };
// const handleStopRecording = () => {
// jamClient.StopRecording({});
// };
// const handlePlayPause = () => {
// if (isPlaying) {
// jamClient.SessionPausePlay();
// } else {
// jamClient.SessionStartPlay();
// }
// };
// const handleStopPlayback = () => {
// jamClient.SessionStopPlay();
// };
// // Callback handlers (these would be implemented to handle WebSocket responses)
// const HandleSessionCallback = (data) => {
// logger.log('Session callback:', data);
// // Handle session events
// };
// const HandleRecordingStarted = (data) => {
// logger.log('Recording started:', data);
// // Update recording state
// };
// const HandleRecordingStopped = (data) => {
// logger.log('Recording stopped:', data);
// // Update recording state
// };
// const HandleVolumeChangeCallback = (mixerId, isLeft, value, isMuted) => {
// logger.log('Volume changed:', { mixerId, isLeft, value, isMuted });
// // Update mixer state
// };
// const HandleBridgeCallback = (vuData) => {
// logger.log('Bridge callback:', vuData);
// // Handle VU meter updates
// };
useEffect(() => {
fetchFriends();
}, []);
const fetchFriends = () => {
if (currentUser) {
getFriends(currentUser.id)
.then(resp => {
if (resp.ok) {
return resp.json();
}
})
.then(data => {
setFriends(data);
});
}
};
const handleRecordingSubmit = async (settings) => {
settings.volume = getCurrentRecordingState().inputVolumeLevel;
try {
localStorage.setItem("recordSettings", JSON.stringify(settings));
} catch (e) {
logger.info("error while saving recordSettings to localStorage");
logger.log(e.stack);
}
const params = {
recordingType: settings.recordingType,
name: settings.recordingName,
audioFormat: settings.audioFormat,
audioStoreType: settings.audioStoreType,
includeChat: settings.includeChat,
volume: settings.volume,
};
if (params.recordingType === RECORD_TYPE_BOTH) {
params['videoFormat'] = settings.videoFormat;
params['audioDelay'] = settings.audioDelay;
const obsAvailable = await jamClient.IsOBSAvailable();
if (!obsAvailable) {
toast.warning("OBS Studio is not available. Please ensure OBS Studio is installed and running to record video.");
return;
}
if (!globalObject.JK.videoIsOngoing) {
toast.warning("To make a video recording in JamKazam you must have an ongoing video. You can start a video by clicking the Video button on session tool bar.");
return;
}
}
//this.startStopRecording(params);
//TODO: handle startStopRecording
doStartRecording(params);
}
function groupTracksToClient(recording) {
// group N tracks to the same client Id
let groupedTracks = {};
let recordingTracks = recording["recorded_tracks"];
for (let i = 0; i < recordingTracks.length; i++) {
let clientId = recordingTracks[i].client_id;
let tracksForClient = groupedTracks[clientId];
if (!tracksForClient) {
tracksForClient = [];
groupedTracks[clientId] = tracksForClient;
}
tracksForClient.push(recordingTracks[i]);
}
return dkeys(groupedTracks);
}
const doStartRecording = (params) => {
startRecording({ music_session_id: currentSession.id, recordVideo: params.recordVideo }).then(async (recording) => {
const currentRecordingId = recording.id;
console.debug("Recording started with ID: ", currentRecordingId);
const groupedTracks = groupTracksToClient(recording);
try {
await jamClient.StartMediaRecording(currentRecordingId, groupedTracks, params);
} catch (error) {
console.error("Error starting media recording:", error);
}
}).catch((error) => {
console.error("Error starting recording:", error);
});
}
const handleLeaveSession = () => {
// Just show the modal - no leave operations yet
dispatch(openModal('leave'));
};
const handleLeaveSubmit = async (feedbackData) => {
try {
setLeaveLoading(true);
// Unregister message callbacks before leaving
unregisterMessageCallbacks();
// Close metronome if open before leaving
if (metronomeState.isOpen) {
console.log('Closing metronome before leaving session');
closeMetronome();
}
// Submit feedback to backend first
const clientId = server.clientId;
const backendDetails = jamClient.getAllClientsStateMap ? jamClient.getAllClientsStateMap() : {};
await submitSessionFeedback(clientId, {
rating: feedbackData.rating,
comment: feedbackData.comments,
backend_details: backendDetails
});
// Then perform leave operations
await sessionModel.handleLeaveSession();
// Clear Redux session state
dispatch(clearSession());
dispatch(closeModal('leave'));
toast.success('Thank you for your feedback!');
// Navigate to sessions page using React Router
history.push('/sessions');
} catch (error) {
console.error('Error submitting feedback or leaving session:', error);
toast.error('Failed to submit feedback or leave session');
} finally {
setLeaveLoading(false);
}
};
// Cleanup metronome state and message callbacks when component unmounts
useEffect(() => {
return () => {
// Unregister message callbacks
unregisterMessageCallbacks();
// Reset metronome state when component unmounts (session ends)
if (metronomeState.isOpen) {
console.log('Resetting metronome state on session cleanup');
resetMetronome();
}
// Clear Redux session state on unmount
dispatch(clearSession());
};
}, [metronomeState.isOpen, resetMetronome, dispatch]);
// Check if user can use video (subscription/permission check)
const canVideo = () => {
// This would need to be implemented based on user subscription logic
console.debug("JKSessionScreen: Checking video permission for user", currentSession);
return currentSession?.can_use_video || false;
};
// Open external link in new window/tab
const openExternalLink = (url) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
// Handle video button click - opens new video conferencing server
const handleVideoClick = async () => {
if (!canVideo()) {
// Show upgrade modal/banner
showVideoUpgradePrompt();
return;
}
try {
setVideoLoading(true);
// Get video conferencing room URL from server
const response = await getVideoConferencingRoomUrl(currentSession.id);
const videoUrl = `${response.url}&audiooff=true`;
// Open video URL in new browser window/tab
console.debug("JKSessionScreen: Opening video conferencing URL", videoUrl);
openExternalLink(videoUrl);
} catch (error) {
console.error('Failed to get video room URL:', error);
// Handle error - could show error message to user
toast.error('Failed to start video session');
} finally {
// Keep loading state for 10 seconds to prevent multiple clicks
setTimeout(() => setVideoLoading(false), 10000);
}
};
// Show upgrade prompt for users without video permissions
const showVideoUpgradePrompt = () => {
// Implementation for showing upgrade modal/banner
// This could use a modal context or toast notification
console.log('Show video upgrade prompt');
toast.warning(<VideoUpgradeContent />, { autoClose: false });
};
const VideoUpgradeContent = () => (
<div>
<h5>Upgrade Required</h5>
<p>The video feature requires a premium subscription. Please upgrade your plan to access video conferencing.</p>
<Button color="primary" onClick={() => {
// Redirect to upgrade page
history.push('/upgrade');
}}>
Upgrade Now
</Button>
</div>
);
const handleBroadcast = async (e) => {
e.preventDefault();
try {
await jamClient.LaunchBroadcastSettings();
} catch (e) {
console.error(e);
}
};
// Handle Resync button click - performs audio resync via native client
const handleResync = useCallback(async (e) => {
e.preventDefault();
if (resyncLoading) return;
setResyncLoading(true);
try {
await resyncAudio();
// Silent success (matches legacy behavior)
} catch (error) {
console.error('Audio resync failed:', error);
if (error.message === 'timeout') {
toast.error('Audio resync timed out. Please try again.');
} else {
toast.error('Audio resync failed: ' + (error.message || 'Unknown error'));
}
} finally {
setResyncLoading(false);
}
}, [resyncAudio, resyncLoading]);
// Attach button handlers
const handleAttachClick = useCallback(() => {
if (attachFileInputRef.current) {
attachFileInputRef.current.click();
}
}, []);
const handleFileSelect = useCallback((e) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset input for re-selection of same file
e.target.value = '';
// Validate file before upload
const validation = validateFile(file);
if (!validation.valid) {
toast.error(validation.error);
return;
}
// Show warnings if any (e.g., backend may not fully support this type)
if (validation.warnings) {
validation.warnings.forEach(warning => {
console.warn('Attachment warning:', warning);
});
}
// Open chat window if not already open
dispatch(openModal('chat'));
// Dispatch upload with user info for optimistic update
// (sender is excluded from WebSocket broadcast, so we add message locally)
dispatch(uploadAttachment({
file,
sessionId,
clientId: server?.clientId,
userId: currentUser?.id,
userName: currentUser?.name
}));
}, [dispatch, sessionId, server?.clientId, currentUser?.id, currentUser?.name]);
// Show error toast when upload fails
useEffect(() => {
if (uploadError) {
toast.error(uploadError);
dispatch(clearUploadError());
}
}, [uploadError, dispatch]);
// Show success toast when upload completes
useEffect(() => {
// Show success toast when upload transitions from 'uploading' to 'idle'
if (prevUploadStatusRef.current === 'uploading' && uploadStatus === 'idle') {
toast.success('File uploaded successfully', { autoClose: 3000 });
}
prevUploadStatusRef.current = uploadStatus;
}, [uploadStatus]);
const handleBackingTrackSelected = async (result) => {
console.log('JKSessionScreen: handleBackingTrackSelected called with:', result);
console.log('JKSessionScreen: Current state - showBackingTrackPopup:', showBackingTrackPopup, 'popupGuard:', popupGuard);
try {
console.log('JKSessionScreen: Calling jamClient.SessionOpenBackingTrackFile...');
// Open the backing track file
await jamClient.SessionOpenBackingTrackFile(result.file, false);
console.log('JKSessionScreen: jamClient.SessionOpenBackingTrackFile completed');
// Set up data for the popup (don't store jamClient in Redux - it's not serializable)
console.log('JKSessionScreen: Setting backing track data...');
dispatch(setBackingTrackData({
backingTrack: result.file,
session: currentSession,
currentUser: currentUser
}));
// Show the popup
console.log('JKSessionScreen: Setting showBackingTrackPopup to true...');
dispatch(openModal('backingTrack'));
console.log('JKSessionScreen: handleBackingTrackSelected completed successfully');
//TODO: In the legacy client, the popup window was opened as a native window through the client. decide whether we need to replicate that behavior here or do it through the browser only
} catch (error) {
console.error('JKSessionScreen: Error opening backing track:', error);
toast.error('Failed to open backing track');
}
};
const handleJamTrackSelect = async (jamTrack) => {
console.log('Jam track selected:', jamTrack);
try {
// Fetch jam track details with stems
const response = await getJamTrack({ id: jamTrack.id });
const jamTrackWithStems = await response.json();
console.log('Jam track data:', jamTrackWithStems);
// Set the selected jam track and stems (for display on session screen)
dispatch(setSelectedJamTrack(jamTrackWithStems));
dispatch(setJamTrackStems(jamTrackWithStems.tracks || []));
// Open the JamTrack player popup (with full data needed for player)
dispatch(setJamTrackData({
jamTrack: jamTrackWithStems,
session: currentSession,
currentUser: currentUser
}));
toast.success(`Loaded JamTrack: ${jamTrackWithStems.name}`);
} catch (error) {
console.error('Error loading jam track:', error);
toast.error('Failed to load JamTrack');
}
};
const handleMetronomeSelected = async () => {
console.log('Opening metronome');
try {
// Check if currently recording - can't open metronome while recording
if (currentlyRecording) {
toast.warning("You can't open a metronome while recording.");
return;
}
// Check for unstable NTP clocks (like legacy implementation)
const unstableClocks = await checkUnstableClocks();
if (currentSession.participants && currentSession.participants.length > 1 && unstableClocks.length > 0) {
const names = unstableClocks.join(", ");
toast.warning(`Couldn't open metronome due to unstable clocks: ${names}`);
return;
}
// Track analytics (like legacy SessionStore)
if (window.stats && window.stats.write) {
const data = {
value: 1,
session_size: currentSession.participants?.length || 1,
user_id: currentUser?.id,
user_name: currentUser?.name
};
window.stats.write('web.metronome.open', data);
}
// Stop any current playback first (like legacy MixerStore)
await jamClient.SessionStopPlay();
// Default metronome settings for the controls
const bpm = 120;
const sound = 2; // Beep (numeric value)
const soundName = "Beep"; // String name for native client
const meter = 1;
const cricket = false;
console.log(`Opening metronome controls with default settings: bpm=${bpm}, sound=${soundName}, meter=${meter}`);
// Inform server about metronome opening (like legacy SessionStore)
await openMetronome({ id: currentSession.id });
// Initialize metronome track (creates mixers) then stop playback
// This allows the track to appear in the UI while audio remains stopped
// Use SessionStopPlay instead of SessionCloseMetronome to preserve metronome state
await jamClient.SessionOpenMetronome(bpm, soundName, meter, 0);
await jamClient.SessionStopPlay();
// Update local metronome state to show popup immediately
// Metronome track will be visible but audio is stopped (user must click Play)
if (updateMetronomeState) {
updateMetronomeState({
isOpen: true,
bpm: bpm,
sound: sound,
meter: meter,
cricket: cricket
});
}
toast.success('Metronome opened successfully');
} catch (error) {
console.error('Error opening metronome:', error);
toast.error('Failed to open metronome');
}
};
const checkUnstableClocks = async () => {
try {
const unstable = [];
// Check current user's NTP stability
const myState = await jamClient.getMyNetworkState();
if (!myState.ntp_stable) {
unstable.push('this computer');
}
// Check other participants' NTP stability
if (currentSession.participants) {
for (const participant of currentSession.participants) {
if (participant.client_id !== server.clientId) {
try {
const peerState = await jamClient.getPeerState(participant.client_id);
if (!peerState.ntp_stable) {
unstable.push(participant.user.first_name + ' ' + participant.user.last_name);
}
} catch (error) {
// Ignore errors for individual peer checks
}
}
}
}
return unstable;
} catch (error) {
console.error('Error checking NTP stability:', error);
return [];
}
};
return (
<Card>
{!isConnected && <div className='d-flex align-items-center'>Connecting to backend...</div>}
<FalconCardHeader title="Session" titleClass="font-weight-bold">
<Button color="primary" size="md" onClick={handleLeaveSession}>Leave Session</Button>
</FalconCardHeader>
<CardHeader className="bg-light border-bottom border-top py-2 border-3">
<div className="d-flex flex-nowrap overflow-auto" style={{ gap: '0.5rem', zIndex: 1100 }}>
<Button className='btn-custom-outline' outline size="md" onClick={() => dispatch(openModal('settings'))}>
<img src={gearIcon} alt="Settings" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Settings</Button>
<Button className='btn-custom-outline' outline size="md" onClick={() => dispatch(openModal('invite'))}>
<img src={inviteIcon} alt="Invite" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Invite</Button>
<Button className='btn-custom-outline' outline size="md" onClick={() => dispatch(openModal('volume'))}>
<img src={volumeIcon} alt="Volume" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Volume</Button>
<Button className='btn-custom-outline' outline size="md" onClick={handleVideoClick} disabled={videoLoading}>
<img src={videoIcon} alt="Video" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
{videoLoading && (<Spinner size="sm" />)}
&nbsp;Video
</Button>
<Button className='btn-custom-outline' outline size="md" onClick={() => dispatch(openModal('recording'))}>
<img src={recordIcon} alt="Record" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Record</Button>
<Button className='btn-custom-outline' outline size="md" onClick={ handleBroadcast}>
<img src={broadcastIcon} alt="Broadcast" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Broadcast</Button>
<JKSessionOpenMenu onBackingTrackSelected={handleBackingTrackSelected} onJamTrackSelected={() => dispatch(openModal('jamTrack'))} onMetronomeSelected={handleMetronomeSelected} />
<JKSessionChatButton sessionId={sessionId} />
<input
type="file"
ref={attachFileInputRef}
style={{ display: 'none' }}
accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav"
onChange={handleFileSelect}
/>
<Button
className='btn-custom-outline'
outline
size="md"
onClick={handleAttachClick}
disabled={isUploading}
>
<img src={attachIcon} alt="Attach" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
{isUploading ? 'Uploading...' : 'Attach'}
</Button>
<Button className='btn-custom-outline' outline size="md" onClick={handleResync} disabled={resyncLoading}>
<img src={resyncIcon} alt="Resync" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
{resyncLoading ? <><Spinner size="sm" /> Resyncing...</> : 'Resync'}
</Button>
</div>
</CardHeader>
<CardBody className="pl-4" style={{ backgroundColor: '#edf2f9f5', overflowX: 'auto', width: '100%' }}>
<div className='d-flex' style={{ gap: '1rem' }}>
<div className='audioInputs'>
<h5>Audio Inputs <FontAwesomeIcon icon="question-circle" id="audioInputsTooltip" className="ml-2" style={{ cursor: 'pointer' }} /></h5>
<div style={{ borderRight: '1px #ddd solid', paddingRight: '1rem' }}>
<JKSessionAudioInputs
myTracks={mixerHelper.myTracks}
chat={chat}
mixerHelper={mixerHelper}
isRemote={false}
/>
</div>
</div>
<div className='sessionMix'>
<h5>Session Mix <FontAwesomeIcon icon="question-circle" id="sessionMixTooltip" className="ml-2" style={{ cursor: 'pointer' }} /></h5>
<div className='d-flex' style={{ gap: '1rem' }}>
<JKSessionAudioInputs
myTracks={mixerHelper.myTracks}
chat={chat}
mixerHelper={mixerHelper}
isRemote={true}
/>
<JKSessionRemoteTracks
mixerHelper={mixerHelper}
sessionModel={sessionModel}
/>
</div>
</div>
{/* JamTrack Section - Show stems when player is ready */}
{selectedJamTrack && jamTrackStems.length > 0 &&
(jamTrackDownloadState.state === 'synchronized' || jamTrackDownloadState.state === 'idle') && (
<>
<div style={{ borderLeft: '1px solid #ddd', paddingLeft: '1rem' }}></div>
<div className='jamTrack'>
<h5>
JamTrack: {selectedJamTrack.name}
<a
href="#"
className="text-muted ml-2"
onClick={(e) => {
e.preventDefault();
handleJamTrackClose();
}}
style={{ fontSize: '1.2em', textDecoration: 'none' }}
title="Close JamTrack"
>
<FontAwesomeIcon icon="times" /> Close
</a>
</h5>
<JKSessionJamTrackStems
jamTrackStems={jamTrackStems}
mixerHelper={mixerHelper}
/>
</div>
</>
)}
{/* Backing Track Section - Show track when player is open */}
{showBackingTrackPlayer && mixerHelper.backingTracks && mixerHelper.backingTracks.length > 0 && (
<>
<div style={{ borderLeft: '1px solid #ddd', paddingLeft: '1rem' }}></div>
<div className='backingTrack'>
<h5>
Backing Track: {mixerHelper.backingTracks[0].shortFilename || 'Audio File'}
<a
href="#"
className="text-muted ml-2"
onClick={(e) => {
e.preventDefault();
handleBackingTrackMainClose();
}}
style={{ fontSize: '1.2em', textDecoration: 'none' }}
title="Close Backing Track"
>
<FontAwesomeIcon icon="times" /> Close
</a>
</h5>
<JKSessionBackingTrack
backingTrack={mixerHelper.backingTracks[0]}
mixers={mixerHelper.backingTracks[0].mixers}
onClose={handleBackingTrackMainClose}
/>
</div>
</>
)}
{/* Metronome Section - Show track when metronome is open */}
{metronomeState.isOpen && metronomeTrackMixers && metronomeTrackMixers.length > 0 && (
<>
<div style={{ borderLeft: '1px solid #ddd', paddingLeft: '1rem' }}></div>
<div className='metronomeTrack'>
<h5>
Metronome
<a
href="#"
className="text-muted ml-2"
onClick={(e) => {
e.preventDefault();
handleMetronomeClose();
}}
style={{ fontSize: '1.2em', textDecoration: 'none' }}
title="Close Metronome"
>
<FontAwesomeIcon icon="times" /> Close
</a>
</h5>
<JKSessionMetronome
mixers={metronomeTrackMixers[0]}
onClose={handleMetronomeClose}
/>
</div>
</>
)}
</div>
{/* Connection Status Alerts */}
{showConnectionAlert && (
<div className='mt-4'>
<Alert color={
connectionStatus === ConnectionStatus.DISCONNECTED ? 'warning' :
connectionStatus === ConnectionStatus.RECONNECTING ? 'info' :
connectionStatus === ConnectionStatus.ERROR ? 'danger' : 'success'
}>
<div className='d-flex align-items-center'>
<div className='me-2'>
{connectionStatus === ConnectionStatus.DISCONNECTED && '⚠️'}
{connectionStatus === ConnectionStatus.RECONNECTING && '🔄'}
{connectionStatus === ConnectionStatus.ERROR && '❌'}
{connectionStatus === ConnectionStatus.CONNECTED && '✅'}
</div>
<div>
{connectionStatus === ConnectionStatus.DISCONNECTED && (
<strong>Connection Lost</strong>
)}
{connectionStatus === ConnectionStatus.RECONNECTING && (
<div>
<strong>Reconnecting...</strong>
{reconnectAttempts > 0 && (
<div className='small mt-1'>
Attempt {reconnectAttempts} of 10
</div>
)}
</div>
)}
{connectionStatus === ConnectionStatus.ERROR && (
<div>
<strong>Connection Failed</strong>
{lastError && (
<div className='small mt-1'>
{lastError.message || 'Unknown error'}
</div>
)}
</div>
)}
{connectionStatus === ConnectionStatus.CONNECTED && (
<strong>Reconnected Successfully</strong>
)}
</div>
</div>
</Alert>
</div>
)}
{/* Connection Status Badge in Header */}
<div className='mt-3 mb-3'>
<Badge
color={
connectionStatus === ConnectionStatus.CONNECTED ? 'success' :
connectionStatus === ConnectionStatus.CONNECTING ? 'warning' :
connectionStatus === ConnectionStatus.RECONNECTING ? 'info' :
connectionStatus === ConnectionStatus.ERROR ? 'danger' : 'secondary'
}
className='me-2'
>
{connectionStatus === ConnectionStatus.CONNECTED && '🟢 Connected'}
{connectionStatus === ConnectionStatus.CONNECTING && '🟡 Connecting...'}
{connectionStatus === ConnectionStatus.RECONNECTING && '🔄 Reconnecting...'}
{connectionStatus === ConnectionStatus.DISCONNECTED && '🟠 Disconnected'}
{connectionStatus === ConnectionStatus.ERROR && '🔴 Error'}
</Badge>
{reconnectAttempts > 0 && (
<Badge color='info' className='me-2'>
Attempt {reconnectAttempts}/10
</Badge>
)}
</div>
{/* Debug Info */}
{/* <div className='mt-4 p-3 bg-white rounded'>
<h6>Debug Information</h6>
<div className='row'>
<div className='col-md-3'>
<strong>Connection:</strong> {isConnected ? '✅ Connected' : '❌ Disconnected'}
</div>
<div className='col-md-3'>
<strong>Status:</strong> {connectionStatus}
</div>
</div>
<div className='row mt-2'>
<div className='col-md-3'>
<strong>Reconnect Attempts:</strong> {reconnectAttempts}
</div>
<div className='col-md-6'>
<strong>Last Error:</strong> {lastError ? lastError.message : 'None'}
</div>
</div>
<div className='row mt-2'>
<div className='col-md-12'>
<strong>Current Session:</strong> {JSON.stringify(currentSession)}
</div>
</div>
</div> */}
</CardBody>
<JKSessionSettingsModal
isOpen={showSettingsModal}
toggle={() => dispatch(toggleModal('settings'))}
currentSession={{ ...currentSession, privacy: musicianAccess }}
loading={settingsLoading}
onSave={async (payload) => {
console.log('Session settings :', payload);
try {
setSettingsLoading(true);
switch (parseInt(payload.privacy)) {
case SESSION_PRIVACY_MAP['public']:
payload.musician_access = true;
payload.approval_required = false;
break;
case SESSION_PRIVACY_MAP['private_approve']:
payload.musician_access = true;
payload.approval_required = true;
break;
case SESSION_PRIVACY_MAP['private_invite']:
payload.musician_access = false;
payload.approval_required = false;
break;
default:
break;
}
const response = await updateSessionSettings({
id: currentSessionIdRef.current,
...payload
});
const data = await response.json();
console.log('Updated session settings response:', data);
dispatch(updateSessionData(data)); // Phase 4: dispatch to Redux
dispatch(closeModal('settings'));
toast.success('Session settings updated successfully');
} catch (error) {
console.error('Error updating session settings:', error);
toast.error('Failed to update session settings');
} finally {
setSettingsLoading(false);
}
}}
/>
<JKSessionInviteModal
currentSession={currentSession}
show={showInviteModal}
size="lg"
onToggle={() => dispatch(closeModal('invite'))}
friends={friends}
initialInvitees={sessionInvitees}
loading={inviteLoading}
onSubmit={async (invitees) => {
setSessionInvitees(invitees);
console.log('Submitted invitees:', invitees);
const inviteeIds = invitees.map(i => i.id)
const payload = {
inviteeIds: inviteeIds.join()
};
try {
setInviteLoading(true);
const response = await updateSessionSettings({
id: currentSessionIdRef.current,
...payload
});
const data = await response.json();
console.log('Updated session settings response:', data);
dispatch(updateSessionData(data)); // Phase 4: dispatch to Redux
dispatch(closeModal('invite'));
toast.success('Invitations sent successfully');
} catch (error) {
console.error('Error updating session settings:', error);
toast.error('Failed to send invitations');
} finally {
setInviteLoading(false);
}
}}
/>
<JKSessionVolumeModal
isOpen={showVolumeModal}
toggle={() => dispatch(toggleModal('volume'))}
/>
<JKSessionRecordingModal
isOpen={showRecordingModal}
toggle={() => dispatch(toggleModal('recording'))}
onSubmit={handleRecordingSubmit}
/>
<JKSessionLeaveModal
isOpen={showLeaveModal}
toggle={() => dispatch(closeModal('leave'))} // Just close modal, don't navigate since session not left yet
onSubmit={handleLeaveSubmit}
loading={leaveLoading}
/>
<UncontrolledTooltip target="audioInputsTooltip" trigger="hover click">
<div>
<p>Set the input level of your Audio Inputs for each of your tracks to a healthy level. It's important to set your input level correctly. If your level is set too high, you'll get distortion or clipping of your audio. If set too low, your audio signal will be too weak, which can cause noise and degrade your audio quality when you and others use the session mix to increase your volume in the mix.</p>
<p>For instructions on how to set your Audio Input levels, read <a href="/help/audio-input-levels" target="_blank">this help article</a>.</p>
</div>
</UncontrolledTooltip>
<UncontrolledTooltip target="sessionMixTooltip" trigger="hover click">
<div>
<p>Adjust the volume of each audio track (yours and others) in the Session Mix to get the mix where you want it (i.e. where it sounds good and well balanced to you). Any volume changes you make will affect only what you hear. They dont affect the volume of what others hear in the session. Everyone has their own customizable session mix.</p>
<p>Note that your session mix is the mix that will be used for any recordings you make and for any broadcasts you stream. If another musician in your session makes a recording or streams a broadcast, it will use that musicians session mix, not yours.</p>
<p>For instructions on how to set your Session Mix levels, <a href="https://jamkazam.freshdesk.com/support/solutions/articles/66000124834" target="_blank" rel="noopener noreferrer">read this help article</a>.</p>
</div>
</UncontrolledTooltip>
{/* Backing Track Popup */}
{showBackingTrackPopup && backingTrackData && (
<WindowPortal
onClose={handleBackingTrackClose}
windowFeatures="width=500,height=400,left=200,top=200,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no, addressbar=no"
>
<JKSessionBackingTrackPlayer
backingTrack={backingTrackData.backingTrack}
jamClient={jamClient}
session={backingTrackData.session}
currentUser={backingTrackData.currentUser}
isPopup={true}
onClose={() => {
console.log('JKSessionScreen: JKSessionBackingTrackPlayer onClose called');
dispatch(closeModal('backingTrack'));
dispatch(clearBackingTrackData());
}}
/>
</WindowPortal>
)}
{/* Metronome Player Popup */}
{metronomeState.isOpen && (
<WindowPortal
title="Metronome Controls"
onClose={handleMetronomeClose}
windowFeatures="width=450,height=400,left=200,top=200,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no, addressbar=no"
windowId="metronome-controls"
>
<JKSessionMetronomePlayer
isOpen={metronomeState.isOpen}
onClose={handleMetronomeClose}
metronomeState={metronomeState}
jamClient={jamClient}
session={currentSession}
currentUser={currentUser}
isPopup={true}
/>
</WindowPortal>
)}
{/* JamTrack Player Popup */}
{showJamTrackPlayer && jamTrackData && (
<WindowPortal
onClose={handleJamTrackPlayerClose}
windowFeatures="width=600,height=500,left=200,top=200,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no, addressbar=no"
>
<JKSessionJamTrackPlayer
jamTrack={jamTrackData.jamTrack}
jamClient={jamClient}
session={jamTrackData.session}
currentUser={jamTrackData.currentUser}
isPopup={true}
onClose={handleJamTrackClose}
/>
</WindowPortal>
)}
<JKSessionJamTrackModal
isOpen={showJamTrackModal}
toggle={() => dispatch(toggleModal('jamTrack'))}
onJamTrackSelect={handleJamTrackSelect}
/>
{/* Chat Window */}
<JKSessionChatWindow />
{/* Media Controls Popup - Only show when explicitly opened */}
{showMediaControlsPopup && !popupGuard && (() => {
console.log('JKSessionScreen: RENDERING Media Controls Popup - showMediaControlsPopup:', showMediaControlsPopup, 'popupGuard:', popupGuard);
setPopupGuard(true); // Set guard immediately to prevent re-renders
return (
<WindowPortal
onClose={() => {
console.log('JKSessionScreen: Media Controls Popup closing');
dispatch(closeModal('mediaControls'));
setMediaControlsOpened(false);
setPopupGuard(false); // Reset guard when closing
}}
windowFeatures="width=600,height=500,left=250,top=150,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no,addressbar=no"
title="Media Controls"
windowId="media-controls"
>
<JKPopupMediaControls onClose={() => {
console.log('JKSessionScreen: JKPopupMediaControls onClose called');
dispatch(closeModal('mediaControls'));
setMediaControlsOpened(false);
setPopupGuard(false); // Reset guard when closing
}} />
</WindowPortal>
);
})()}
</Card>
)
}
export default memo(JKSessionScreen)