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

1422 lines
55 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';
// Redux imports
import { openModal, closeModal, toggleModal, selectModal } from '../../store/features/sessionUISlice';
import { selectMediaSummary } 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 { 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 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,
} = 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, closeMetronome, resetMetronome } = useGlobalContext();
const { getCurrentRecordingState, reset: resetRecordingState, currentlyRecording } = useRecordingHelpers();
const { SessionPageEnter } = useSessionUtils();
// Redux media state and actions
const mediaSummary = useSelector(selectMediaSummary);
const { openBackingTrack, openMetronome, 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 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);
// Redux backing track state (modal visibility and data)
const backingTrackData = useSelector(selectBackingTrackData);
const showBackingTrackPlayer = Boolean(backingTrackData);
// 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]);
// 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]);
useEffect(() => {
if (!isConnected || !jamClient) return;
console.debug("JKSessionScreen: -DEBUG- isConnected changed to true");
guardOnJoinSession();
}, [isConnected, jamClient]); // Added jamClient to dependencies for stability
const guardOnJoinSession = async () => {
try {
const musicSessionResp = await getSessionHistory(sessionIdFromUrl);
const musicSession = await musicSessionResp.json();
logger.log("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("musicianAccessOnJoin when joining session: " + musicianAccessOnJoin);
logger.log("clientRole when joining session: " + clientRole);
logger.log("currentSessionId when joining session: " + currentSessionIdRef.current);
if (clientRole === CLIENT_ROLE.CHILD) {
logger.debug("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("SessionPageEnter result: ", result);
try {
await guardAgainstActiveProfileMissing(app, result);
logger.log("user has an active profile");
try {
const tracks = await sessionModel.waitForSessionPageEnterDone();
dispatch(setUserTracks(tracks));
logger.log("userTracks: ", tracks);
try {
await ensureAppropriateProfile(musicianAccessOnJoin)
logger.log("user has passed all session guards")
dispatch(setGuardsPassed(true))
} catch (error) {
logger.error("User profile is not appropriate for session:", error);
if (!error.controlled_location) {
}
}
} catch (error) {
logger.error("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("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("Error: Active profile is missing or invalid:", error);
}
} catch (error) {
// Invalid configuration, redirect to home
await sessionModel.handleLeaveSession(); //TODO: handle redirection
logger.error("Error: Invalid configuration:", error);
}
} catch (error) {
logger.error("Error: Error fetching session history:", error);
//TODO: Show some error
}
};
useEffect(() => {
if (!sessionGuardsPassed || userTracks.length === 0 || hasJoined) { return }
joinSession();
}, [sessionGuardsPassed, userTracks, hasJoined])
const joinSession = async () => {
await jamClient.SessionRegisterCallback("JK.HandleBridgeCallback2");
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);
// 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 }
];
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]);
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);
}
};
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();
// Open the metronome with default settings
const bpm = 120;
const sound = "Beep";
const meter = 1;
const mode = 0;
console.log(`Opening metronome with bpm: ${bpm}, sound: ${sound}, meter: ${meter}, mode: ${mode}`);
// Inform server about metronome opening (like legacy SessionStore)
await openMetronome({ id: currentSession.id });
// Start the metronome audio (backend will handle GUI via callback)
//alert('About to start metronome');
const result = await jamClient.SessionOpenMetronome(bpm, sound, meter, mode);
//alert('Metronome is started ' + JSON.stringify(result));
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} />
<Button className='btn-custom-outline' outline size="md">
<img src={chatIcon} alt="Chat" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Chat</Button>
<Button className='btn-custom-outline' outline size="md">
<img src={attachIcon} alt="Attach" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
Attach</Button>
<Button className='btn-custom-outline' outline size="md">
<img src={resyncIcon} alt="Resync" style={{ width: '16px', height: '16px', marginRight: '0.2rem' }} />
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>
</>
)}
</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>
)}
{/* 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}
/>
{/* 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)