diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d621b9935..57735fbc6 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -21,7 +21,19 @@
"Bash(chmod:*)",
"Bash(git mv:*)",
"Bash(./run-phase-tests.sh:*)",
- "Bash(npm run test:unit:*)"
+ "Bash(npm run test:unit:*)",
+ "Bash(find:*)",
+ "Bash(npm test:*)",
+ "Bash(node -e:*)",
+ "Bash(npm run build:*)",
+ "Bash(export NODE_OPTIONS=\"--openssl-legacy-provider\")",
+ "Bash(timeout 180 npm run build:*)",
+ "Bash(npx jest:*)",
+ "Bash(npx eslint:*)",
+ "Bash(lsof:*)",
+ "Bash(xargs kill -9)",
+ "Bash(node -c:*)",
+ "Skill(gsd:new-project)"
]
}
}
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
new file mode 100644
index 000000000..19f831f7e
--- /dev/null
+++ b/.planning/PROJECT.md
@@ -0,0 +1,132 @@
+# JamKazam Media Features Modernization
+
+**One-liner:** Modernize media opening features (Backing Track, JamTrack, Metronome) from legacy jQuery/Rails to React patterns in jam-ui
+
+## Vision
+
+Transform the media opening workflow from the legacy web project into modern React patterns in jam-ui. The features exist in the legacy codebase with jQuery dialogs and polling-based playback monitoring - we're bringing them into the React architecture with proper component structure, hooks, and Redux integration where appropriate.
+
+**Target Features:**
+1. **Backing Track** - File-based audio playback with player controls (FIRST PRIORITY)
+2. **JamTrack** - Collaborative track loading with mixdown selection
+3. **Metronome** - Tempo/sound configuration and playback
+
+All three features are accessible via the "Open" menu in the session screen top navigation.
+
+## Context
+
+### Current State
+
+**Backing Track (Partially Implemented):**
+- ✓ Native file dialog integration via `jamClient.ShowSelectBackingTrackDialog()`
+- ✓ File opening via `jamClient.SessionOpenBackingTrackFile()`
+- ✓ Track display in session with VU meter, gain, pan controls
+- ✓ Basic player modal with play/pause/stop buttons
+- ✓ Volume control working
+- ✓ Loop toggle implemented
+- ✗ **Seek bar** - Exists but is placeholder (hardcoded value="0")
+- ✗ **Duration display** - Hardcoded to "0:00"
+- ✗ **Current time display** - Hardcoded to "0:00"
+
+**JamTrack:** Not yet implemented in jam-ui
+
+**Metronome:** Not yet implemented in jam-ui
+
+### Architecture Context
+
+- **Frontend:** React 16.13.1 SPA with Redux Toolkit 1.6.1
+- **Native Bridge:** C++ desktop client exposed via `jamClientProxy.js` QWebChannel tunnel
+- **State Management:** Redux for global state, local component state for transient playback data
+- **Legacy Reference:** jQuery-based implementation in `web/app/assets/javascripts/` using 500ms polling
+
+### Recent Work
+
+**Phase 5 Redux Migration (Completed):**
+- Migrated MediaContext to Redux (`mediaSlice.js`, `mixersSlice.js`)
+- Eliminated duplicate state across contexts and hooks
+- Fixed VU meter bug with personal mixer mode flag handling
+- Established clean separation: Redux for data, local state for UI
+
+## Requirements
+
+### Validated
+
+- ✓ Native file dialog for track selection - existing pattern works well
+- ✓ jamClient API integration - proven in current partial implementation
+- ✓ Redux state management architecture - validated in Phase 5 migration
+
+### Active
+
+**Backing Track (Priority 1):**
+- [ ] Implement real-time playback monitoring with 500ms polling
+- [ ] Display current playback position (MM:SS format)
+- [ ] Display total track duration (MM:SS format)
+- [ ] Implement functional seek bar with drag-to-position
+- [ ] Integrate monitoring lifecycle with play/pause/stop controls
+- [ ] Add error handling for edge cases (duration=0, position>duration)
+- [ ] Optimize performance (prevent unnecessary re-renders)
+
+**JamTrack (Priority 2):**
+- [ ] Research legacy implementation patterns
+- [ ] Design React component structure
+- [ ] Implement file opening workflow
+- [ ] Implement mixdown selection (full track vs. specific mixes)
+- [ ] Implement player controls similar to Backing Track
+
+**Metronome (Priority 3):**
+- [ ] Research legacy implementation patterns
+- [ ] Design React component structure
+- [ ] Implement tempo/sound/cricket configuration UI
+- [ ] Implement start/stop controls
+- [ ] Integrate with metronome mixer state
+
+### Out of Scope
+
+- **React-based track list dialog** - Keep native file dialogs (established pattern)
+- **Audio processing logic** - All audio handled by native C++ client
+- **New media types** - Focus only on existing three features
+- **Refactoring unrelated code** - Minimize changes outside media features
+
+## Key Decisions
+
+| Decision | Rationale | Outcome |
+|----------|-----------|---------|
+| Keep native file dialogs | Already working, platform-native UX, no need to rebuild | Confirmed |
+| Start with Backing Track | Most straightforward, establishes patterns for JamTrack/Metronome | In Progress |
+| Use local state for playback monitoring | High-frequency transient data doesn't need global Redux visibility | Pending |
+| 500ms polling interval | Matches legacy pattern, balances responsiveness vs. performance | Pending |
+| Milliseconds for seek values | Direct mapping to jamClient API, no conversion overhead | Pending |
+
+## Constraints
+
+### Technical
+
+- **Must use jamClient API methods:** SessionGetTracksPlayDurationMs(), SessionCurrrentPlayPosMs(), isSessionTrackPlaying(), SessionTrackSeekMs()
+- **React 16 patterns only** - No async/await in useEffect without proper cleanup
+- **Maintain 60fps VU meter performance** - Playback monitoring must not degrade mixer rendering
+
+### Business
+
+- **Functional parity with legacy web** - Users expect same capabilities in jam-ui
+- **Zero audio glitches** - Seek/playback changes must be seamless
+
+## Critical Files
+
+**Backing Track Implementation:**
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js` - Main work (lines 1-100 reviewed)
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/components/client/JKSessionOpenMenu.js` - Entry point
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/components/client/JKSessionBackingTrack.js` - Track display
+
+**Legacy Reference:**
+- `/Users/nuwan/Code/jam-cloud/web/app/assets/javascripts/dialog/openBackingTrackDialog.js` - Pattern reference
+- `/Users/nuwan/Code/jam-cloud/web/app/assets/javascripts/playbackControls.js` - Polling/formatting reference
+
+**Redux State:**
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/store/features/activeSessionSlice.js` - backingTrackData
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/store/features/mediaSlice.js` - openBackingTrack thunk
+
+**Native Bridge:**
+- `/Users/nuwan/Code/jam-cloud/jam-ui/src/services/jamClientProxy.js` - API reference
+
+---
+*Last updated: 2026-01-13 after initialization*
diff --git a/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js b/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
index 8008c2605..9c0bc1d49 100644
--- a/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
+++ b/jam-ui/src/components/client/JKSessionBackingTrackPlayer.js
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, ModalHeader, ModalBody, ModalFooter, Button, FormGroup, Label, Input } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlay, faPause, faStop, faVolumeUp } from '@fortawesome/free-solid-svg-icons';
-import { useMediaContext } from '../../context/MediaContext';
+import useMediaActions from '../../hooks/useMediaActions';
const JKSessionBackingTrackPlayer = ({
isOpen,
@@ -13,7 +13,7 @@ const JKSessionBackingTrackPlayer = ({
currentUser,
isPopup = false
}) => {
- const { closeMedia } = useMediaContext();
+ const { closeMedia } = useMediaActions();
const [isPlaying, setIsPlaying] = useState(false);
const [isLooping, setIsLooping] = useState(false);
const [volume, setVolume] = useState(100);
diff --git a/jam-ui/src/components/client/JKSessionOpenMenu.js b/jam-ui/src/components/client/JKSessionOpenMenu.js
index 622de3359..f55611f0a 100644
--- a/jam-ui/src/components/client/JKSessionOpenMenu.js
+++ b/jam-ui/src/components/client/JKSessionOpenMenu.js
@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect, useContext } from 'react';
import { createPortal } from 'react-dom';
import { useJamClient } from '../../context/JamClientContext';
-import { useMediaContext } from '../../context/MediaContext';
import { useAuth } from '../../context/UserAuth';
import { toast } from 'react-toastify';
import openIcon from '../../assets/img/client/open.svg';
diff --git a/jam-ui/src/components/client/JKSessionScreen.js b/jam-ui/src/components/client/JKSessionScreen.js
index 8040b7596..62cab809b 100644
--- a/jam-ui/src/components/client/JKSessionScreen.js
+++ b/jam-ui/src/components/client/JKSessionScreen.js
@@ -15,8 +15,8 @@ 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 { useMediaContext } from '../../context/MediaContext';
import { useAuth } from '../../context/UserAuth';
+import useMediaActions from '../../hooks/useMediaActions';
import { dkeys } from '../../helpers/utils.js';
@@ -24,6 +24,7 @@ import { getSessionHistory, getSession, joinSession as joinSessionRest, updateSe
// Redux imports
import { openModal, closeModal, toggleModal, selectModal } from '../../store/features/sessionUISlice';
+import { selectMediaSummary } from '../../store/features/mixersSlice';
import {
fetchActiveSession,
joinActiveSession,
@@ -128,7 +129,10 @@ const JKSessionScreen = () => {
const { globalObject, metronomeState, closeMetronome, resetMetronome } = useGlobalContext();
const { getCurrentRecordingState, reset: resetRecordingState, currentlyRecording } = useRecordingHelpers();
const { SessionPageEnter } = useSessionUtils();
- const { mediaSummary, openBackingTrack, openMetronome, loadJamTrack, closeMedia } = useMediaContext();
+
+ // 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
diff --git a/jam-ui/src/components/popups/JKPopupMediaControls.js b/jam-ui/src/components/popups/JKPopupMediaControls.js
index ae4523e5a..3f0fe2a24 100644
--- a/jam-ui/src/components/popups/JKPopupMediaControls.js
+++ b/jam-ui/src/components/popups/JKPopupMediaControls.js
@@ -1,35 +1,54 @@
import React, { useEffect, useState, useCallback } from 'react';
+import { useSelector } from 'react-redux';
import { Button, Modal, ModalHeader, ModalBody, ModalFooter, FormGroup, Label, Input, Table, Row, Col } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlay, faPause, faStop, faVolumeUp, faDownload, faEdit, faTrash, faPlus, faMinus, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
-import { useMediaContext } from '../../context/MediaContext';
import { useAuth } from '../../context/UserAuth';
import { useJamClient } from '../../context/JamClientContext';
import { toast } from 'react-toastify';
+import useMediaActions from '../../hooks/useMediaActions';
+import { selectMediaSummary, selectMetronome } from '../../store/features/mixersSlice';
+import {
+ selectBackingTracks,
+ selectJamTracks,
+ selectRecordedTracks,
+ selectJamTrackState,
+ selectDownloadingJamTrack
+} from '../../store/features/mediaSlice';
+import {
+ selectShowMyMixes,
+ selectShowCustomMixes,
+ selectEditingMixdownId,
+ selectCreatingMixdown,
+ selectCreateMixdownErrors
+} from '../../store/features/sessionUISlice';
const JKPopupMediaControls = ({ onClose }) => {
const { currentUser } = useAuth();
const { jamClient } = useJamClient();
+
+ // Redux state
+ const mediaSummary = useSelector(selectMediaSummary);
+ const backingTracks = useSelector(selectBackingTracks);
+ const jamTracks = useSelector(selectJamTracks);
+ const recordedTracks = useSelector(selectRecordedTracks);
+ const metronome = useSelector(selectMetronome);
+ const jamTrackState = useSelector(selectJamTrackState);
+ const downloadingJamTrack = useSelector(selectDownloadingJamTrack);
+ const showMyMixes = useSelector(selectShowMyMixes);
+ const showCustomMixes = useSelector(selectShowCustomMixes);
+ const editingMixdownId = useSelector(selectEditingMixdownId);
+ const creatingMixdown = useSelector(selectCreatingMixdown);
+ const createMixdownErrors = useSelector(selectCreateMixdownErrors);
+
+ // Redux actions
const {
- mediaSummary,
- backingTracks,
- jamTracks,
- recordedTracks,
- metronome,
- jamTrackState,
- downloadingJamTrack,
- showMyMixes,
- showCustomMixes,
- editingMixdownId,
- creatingMixdown,
- createMixdownErrors,
closeMedia,
- setShowMyMixes,
- setShowCustomMixes,
- setEditingMixdownId,
- setCreatingMixdown,
- setCreateMixdownErrors
- } = useMediaContext();
+ toggleMyMixes,
+ toggleCustomMixes,
+ editMixdown,
+ setMixdownErrors
+ } = useMediaActions();
const [time, setTime] = useState('0:00');
const [isPlaying, setIsPlaying] = useState(false);
@@ -70,10 +89,6 @@ const JKPopupMediaControls = ({ onClose }) => {
}
};
- // Toggle mix sections
- const toggleMyMixes = () => setShowMyMixes(!showMyMixes);
- const toggleCustomMixes = () => setShowCustomMixes(!showCustomMixes);
-
// JamTrack actions
const handleJamTrackPlay = async (jamTrack) => {
try {
@@ -85,7 +100,7 @@ const JKPopupMediaControls = ({ onClose }) => {
const handleMixdownPlay = async (mixdown) => {
try {
- setEditingMixdownId(null);
+ editMixdown(null);
await jamClient.JamTrackActivateMixdown(mixdown);
} catch (error) {
console.error('Error playing mixdown:', error);
@@ -93,12 +108,12 @@ const JKPopupMediaControls = ({ onClose }) => {
};
const handleMixdownEdit = (mixdown) => {
- setEditingMixdownId(mixdown.id);
+ editMixdown(mixdown.id);
};
const handleMixdownSave = async (mixdown) => {
// Implementation for saving mixdown name
- setEditingMixdownId(null);
+ editMixdown(null);
};
const handleMixdownDelete = async (mixdown) => {
@@ -194,7 +209,7 @@ const JKPopupMediaControls = ({ onClose }) => {
defaultValue={mixdown.name}
onKeyDown={(e) => {
if (e.key === 'Enter') handleMixdownSave(mixdown);
- if (e.key === 'Escape') setEditingMixdownId(null);
+ if (e.key === 'Escape') editMixdown(null);
}}
/>
) : (
diff --git a/jam-ui/src/context/MediaContext.js b/jam-ui/src/context/MediaContext.js
deleted file mode 100644
index 338643922..000000000
--- a/jam-ui/src/context/MediaContext.js
+++ /dev/null
@@ -1,255 +0,0 @@
-import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
-import { useJamServerContext } from './JamServerContext';
-import { useJamClient } from './JamClientContext';
-
-// Media types constants
-export const MEDIA_TYPES = {
- BACKING_TRACK: 'backing_track',
- JAM_TRACK: 'jam_track',
- RECORDING: 'recording',
- METRONOME: 'metronome'
-};
-
-// Media states
-export const MEDIA_STATES = {
- CLOSED: 'closed',
- LOADING: 'loading',
- OPEN: 'open',
- ERROR: 'error'
-};
-
-const MediaContext = createContext();
-
-export const MediaProvider = ({ children }) => {
- // Core media state
- const [mediaSummary, setMediaSummary] = useState({
- mediaOpen: false,
- backingTrackOpen: false,
- jamTrackOpen: false,
- recordingOpen: false,
- metronomeOpen: false,
- isOpener: false,
- userNeedsMediaControls: false
- });
-
- // Media data
- const [backingTracks, setBackingTracks] = useState([]);
- const [jamTracks, setJamTracks] = useState([]);
- const [recordedTracks, setRecordedTracks] = useState([]);
- const [metronome, setMetronome] = useState(null);
- const [jamTrackState, setJamTrackState] = useState({});
- const [downloadingJamTrack, setDownloadingJamTrack] = useState(false);
-
- // UI state
- const [showMyMixes, setShowMyMixes] = useState(false);
- const [showCustomMixes, setShowCustomMixes] = useState(false);
- const [editingMixdownId, setEditingMixdownId] = useState(null);
- const [creatingMixdown, setCreatingMixdown] = useState(false);
- const [createMixdownErrors, setCreateMixdownErrors] = useState(null);
-
- // Contexts
- const { jamClient } = useJamClient();
- const { registerMessageCallback, unregisterMessageCallback } = useJamServerContext();
-
- // Message handlers for real-time updates
- const handleMixerChanges = useCallback((sessionMixers) => {
- const session = sessionMixers.session;
- const mixers = sessionMixers.mixers;
-
- setMediaSummary(prev => ({
- ...prev,
- ...mixers.mediaSummary
- }));
-
- setBackingTracks(mixers.backingTracks || []);
- setJamTracks(mixers.jamTracks || []);
- setRecordedTracks(mixers.recordedTracks || []);
- setMetronome(mixers.metronome || null);
- }, []);
-
- const handleJamTrackChanges = useCallback((changes) => {
- setJamTrackState(changes);
- }, []);
-
- // NOTE: Disabled automatic WebSocket message handling to prevent infinite popup loops
- // Message callbacks will be handled manually by components that need them
- // useEffect(() => {
- // if (!jamClient) return;
-
- // const callbacks = [
- // { type: 'MIXER_CHANGES', callback: handleMixerChanges },
- // { type: 'JAM_TRACK_CHANGES', callback: handleJamTrackChanges }
- // ];
-
- // callbacks.forEach(({ type, callback }) => {
- // registerMessageCallback(type, callback);
- // });
-
- // return () => {
- // callbacks.forEach(({ type, callback }) => {
- // unregisterMessageCallback(type, callback);
- // });
- // };
- // }, [jamClient, registerMessageCallback, unregisterMessageCallback, handleMixerChanges, handleJamTrackChanges]);
-
- // Actions
- const openBackingTrack = useCallback(async (file) => {
- try {
- await jamClient.SessionOpenBackingTrackFile(file, false);
- setMediaSummary(prev => ({
- ...prev,
- backingTrackOpen: true,
- userNeedsMediaControls: true
- }));
- } catch (error) {
- console.error('Error opening backing track:', error);
- throw error;
- }
- }, [jamClient]);
-
- const closeMedia = useCallback(async (force = false) => {
- try {
- await jamClient.SessionCloseMedia(force);
- setMediaSummary(prev => ({
- ...prev,
- mediaOpen: false,
- backingTrackOpen: false,
- jamTrackOpen: false,
- recordingOpen: false,
- metronomeOpen: false,
- userNeedsMediaControls: false
- }));
- } catch (error) {
- console.error('Error closing media:', error);
- throw error;
- }
- }, [jamClient]);
-
- const openMetronome = useCallback(async (bpm = 120, sound = "Beep", meter = 1, mode = 0) => {
- try {
- const result = await jamClient.SessionOpenMetronome(bpm, sound, meter, mode);
- setMediaSummary(prev => ({
- ...prev,
- metronomeOpen: true,
- userNeedsMediaControls: true
- }));
- setMetronome({ bpm, sound, meter, mode });
- return result;
- } catch (error) {
- console.error('Error opening metronome:', error);
- throw error;
- }
- }, [jamClient]);
-
- const closeMetronome = useCallback(async () => {
- try {
- await jamClient.SessionCloseMetronome();
- setMediaSummary(prev => ({
- ...prev,
- metronomeOpen: false
- }));
- setMetronome(null);
- } catch (error) {
- console.error('Error closing metronome:', error);
- throw error;
- }
- }, [jamClient]);
-
- // JamTrack actions
- const loadJamTrack = useCallback(async (jamTrack) => {
- try {
- setDownloadingJamTrack(true);
-
- // Load JMep data if available
- if (jamTrack.jmep) {
- const sampleRate = await jamClient.GetSampleRate();
- const sampleRateForFilename = sampleRate === 48 ? '48' : '44';
- const fqId = `${jamTrack.id}-${sampleRateForFilename}`;
-
- await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
- }
-
- // Play/load the jamtrack
- const result = await jamClient.JamTrackPlay(jamTrack.id);
-
- if (!result) {
- throw new Error('Unable to open JamTrack');
- }
-
- setMediaSummary(prev => ({
- ...prev,
- jamTrackOpen: true,
- userNeedsMediaControls: true
- }));
-
- setDownloadingJamTrack(false);
- return result;
- } catch (error) {
- setDownloadingJamTrack(false);
- console.error('Error loading jam track:', error);
- throw error;
- }
- }, [jamClient]);
-
- const closeJamTrack = useCallback(async () => {
- try {
- await jamClient.JamTrackStopPlay();
- setMediaSummary(prev => ({
- ...prev,
- jamTrackOpen: false
- }));
- setJamTrackState({});
- } catch (error) {
- console.error('Error closing jam track:', error);
- throw error;
- }
- }, [jamClient]);
-
- // Context value
- const value = {
- // State
- mediaSummary,
- backingTracks,
- jamTracks,
- recordedTracks,
- metronome,
- jamTrackState,
- downloadingJamTrack,
- showMyMixes,
- showCustomMixes,
- editingMixdownId,
- creatingMixdown,
- createMixdownErrors,
-
- // Actions
- openBackingTrack,
- closeMedia,
- openMetronome,
- closeMetronome,
- loadJamTrack,
- closeJamTrack,
-
- // UI actions
- setShowMyMixes,
- setShowCustomMixes,
- setEditingMixdownId,
- setCreatingMixdown,
- setCreateMixdownErrors
- };
-
- return (
-
- {children}
-
- );
-};
-
-export const useMediaContext = () => {
- const context = useContext(MediaContext);
- if (!context) {
- throw new Error('useMediaContext must be used within a MediaProvider');
- }
- return context;
-};
-
-export default MediaContext;
diff --git a/jam-ui/src/hooks/useMediaActions.js b/jam-ui/src/hooks/useMediaActions.js
new file mode 100644
index 000000000..5674627ce
--- /dev/null
+++ b/jam-ui/src/hooks/useMediaActions.js
@@ -0,0 +1,210 @@
+import { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import {
+ openBackingTrack as openBackingTrackThunk,
+ loadJamTrack as loadJamTrackThunk,
+ closeMedia as closeMediaThunk,
+ clearJamTrackState,
+ updateJamTrackState
+} from '../store/features/mediaSlice';
+import {
+ setMetronome,
+ setMetronomeSettings,
+ updateMediaSummary
+} from '../store/features/mixersSlice';
+import {
+ setShowMyMixes,
+ toggleMyMixes as toggleMyMixesAction,
+ setShowCustomMixes,
+ toggleCustomMixes as toggleCustomMixesAction,
+ setEditingMixdownId,
+ setCreatingMixdown,
+ setCreateMixdownErrors
+} from '../store/features/sessionUISlice';
+import { useJamServerContext } from '../context/JamServerContext';
+
+/**
+ * Custom hook that provides Redux-based media actions
+ * Replaces MediaContext actions with Redux thunks and dispatchers
+ */
+const useMediaActions = () => {
+ const dispatch = useDispatch();
+ const { jamClient } = useJamServerContext();
+
+ /**
+ * Open a backing track file
+ * @param {string} file - Path to the backing track file
+ */
+ const openBackingTrack = useCallback(async (file) => {
+ try {
+ await dispatch(openBackingTrackThunk({ file, jamClient })).unwrap();
+
+ // Update media summary
+ dispatch(updateMediaSummary({
+ backingTrackOpen: true,
+ userNeedsMediaControls: true
+ }));
+ } catch (error) {
+ console.error('Error opening backing track:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Close all media (backing tracks, jam tracks, recordings, metronome)
+ * @param {boolean} force - Force close even if media is playing
+ */
+ const closeMedia = useCallback(async (force = false) => {
+ try {
+ await dispatch(closeMediaThunk({ force, jamClient })).unwrap();
+
+ // Update media summary
+ dispatch(updateMediaSummary({
+ mediaOpen: false,
+ backingTrackOpen: false,
+ jamTrackOpen: false,
+ recordingOpen: false,
+ metronomeOpen: false,
+ userNeedsMediaControls: false
+ }));
+ } catch (error) {
+ console.error('Error closing media:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Open metronome with specified settings
+ * @param {number} bpm - Beats per minute (default: 120)
+ * @param {string} sound - Metronome sound type (default: "Beep")
+ * @param {number} meter - Time signature meter (default: 1)
+ * @param {number} mode - Metronome mode (default: 0)
+ */
+ const openMetronome = useCallback(async (bpm = 120, sound = "Beep", meter = 1, mode = 0) => {
+ try {
+ const result = await jamClient.SessionOpenMetronome(bpm, sound, meter, mode);
+
+ // Update Redux state
+ dispatch(setMetronome({ bpm, sound, meter, mode }));
+ dispatch(setMetronomeSettings({ tempo: bpm, sound, cricket: mode === 1 }));
+ dispatch(updateMediaSummary({
+ metronomeOpen: true,
+ userNeedsMediaControls: true
+ }));
+
+ return result;
+ } catch (error) {
+ console.error('Error opening metronome:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Close the metronome
+ */
+ const closeMetronome = useCallback(async () => {
+ try {
+ await jamClient.SessionCloseMetronome();
+
+ // Update Redux state
+ dispatch(setMetronome(null));
+ dispatch(updateMediaSummary({
+ metronomeOpen: false
+ }));
+ } catch (error) {
+ console.error('Error closing metronome:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Load and play a JamTrack
+ * @param {object} jamTrack - JamTrack object with id and optional jmep data
+ */
+ const loadJamTrack = useCallback(async (jamTrack) => {
+ try {
+ await dispatch(loadJamTrackThunk({ jamTrack, jamClient })).unwrap();
+
+ // Update media summary
+ dispatch(updateMediaSummary({
+ jamTrackOpen: true,
+ userNeedsMediaControls: true
+ }));
+ } catch (error) {
+ console.error('Error loading jam track:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Stop and close the currently playing JamTrack
+ */
+ const closeJamTrack = useCallback(async () => {
+ try {
+ await jamClient.JamTrackStopPlay();
+
+ // Update Redux state
+ dispatch(clearJamTrackState());
+ dispatch(updateMediaSummary({
+ jamTrackOpen: false
+ }));
+ } catch (error) {
+ console.error('Error closing jam track:', error);
+ throw error;
+ }
+ }, [dispatch, jamClient]);
+
+ /**
+ * Update JamTrack playback state (position, playing, etc.)
+ * @param {object} changes - JamTrack state changes
+ */
+ const updateJamTrackPlayback = useCallback((changes) => {
+ dispatch(updateJamTrackState(changes));
+ }, [dispatch]);
+
+ // UI Actions
+ const toggleMyMixes = useCallback(() => {
+ dispatch(toggleMyMixesAction());
+ }, [dispatch]);
+
+ const toggleCustomMixes = useCallback(() => {
+ dispatch(toggleCustomMixesAction());
+ }, [dispatch]);
+
+ const editMixdown = useCallback((mixdownId) => {
+ dispatch(setEditingMixdownId(mixdownId));
+ }, [dispatch]);
+
+ const startCreatingMixdown = useCallback(() => {
+ dispatch(setCreatingMixdown(true));
+ }, [dispatch]);
+
+ const stopCreatingMixdown = useCallback(() => {
+ dispatch(setCreatingMixdown(false));
+ }, [dispatch]);
+
+ const setMixdownErrors = useCallback((errors) => {
+ dispatch(setCreateMixdownErrors(errors));
+ }, [dispatch]);
+
+ return {
+ // Core media actions
+ openBackingTrack,
+ closeMedia,
+ openMetronome,
+ closeMetronome,
+ loadJamTrack,
+ closeJamTrack,
+ updateJamTrackPlayback,
+
+ // UI actions
+ toggleMyMixes,
+ toggleCustomMixes,
+ editMixdown,
+ startCreatingMixdown,
+ stopCreatingMixdown,
+ setMixdownErrors
+ };
+};
+
+export default useMediaActions;
diff --git a/jam-ui/src/hooks/useMixerHelper.js b/jam-ui/src/hooks/useMixerHelper.js
index 45d4cbbb7..6b966ec0b 100644
--- a/jam-ui/src/hooks/useMixerHelper.js
+++ b/jam-ui/src/hooks/useMixerHelper.js
@@ -1,6 +1,51 @@
-import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
-import { useSelector } from 'react-redux';
+import { useEffect, useMemo, useCallback, useRef } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
import { selectActiveSession, selectInSession } from '../store/features/activeSessionSlice';
+import {
+ selectChatMixer,
+ selectBroadcastMixer,
+ selectRecordingMixer,
+ selectRecordingTrackMixers,
+ selectBackingTrackMixers,
+ selectJamTrackMixers,
+ selectMetronomeTrackMixers,
+ selectAdhocTrackMixers,
+ selectMasterMixers,
+ selectPersonalMixers,
+ selectAllMixers,
+ selectMixersByResourceId,
+ selectMixersByTrackId,
+ selectMetronome,
+ selectMetronomeSettings,
+ selectMediaSummary,
+ selectNoAudioUsers,
+ selectClientsWithAudioOverride,
+ selectSimulatedMusicCategoryMixers,
+ selectSimulatedChatCategoryMixers,
+ selectMixersReady,
+ setMasterMixers,
+ setPersonalMixers,
+ organizeMixers,
+ updateMixer,
+ setChatMixer,
+ setBackingTracks as setBackingTracksAction,
+ setJamTracks as setJamTracksAction,
+ setRecordedTracks as setRecordedTracksAction,
+ setMetronome as setMetronomeAction,
+ setMediaSummary,
+ setSimulatedMusicCategoryMixers as setSimulatedMusicAction,
+ setSimulatedChatCategoryMixers as setSimulatedChatAction
+} from '../store/features/mixersSlice';
+import {
+ selectBackingTracks,
+ selectJamTracks,
+ selectRecordedTracks
+} from '../store/features/mediaSlice';
+import {
+ selectMixMode,
+ selectCurrentMixerRange,
+ setCurrentMixerRange
+} from '../store/features/sessionUISlice';
import { ChannelGroupIds, CategoryGroupIds, MIX_MODES, MIDI_TRACK } from '../helpers/globals.js';
import useVuHelpers from './useVuHelpers.js';
import useFaderHelpers from './useFaderHelpers.js';
@@ -13,41 +58,45 @@ import { getAvatarUrl, getInstrumentIcon45, getInstrumentIcon24 } from '../helpe
const useMixerHelper = () => {
+ const dispatch = useDispatch();
const allMixersRef = useRef({});
- const [chatMixer, setChatMixer] = useState(null);
- const [broadcastMixer, setBroadcastMixer] = useState(null);
- const [recordingMixer, setRecordingMixer] = useState(null);
- const [recordingTrackMixers, setRecordingTrackMixers] = useState([]);
- const [backingTrackMixers, setBackingTrackMixers] = useState([]);
- const [jamTrackMixers, setJamTrackMixers] = useState([]);
- const [metronomeTrackMixers, setMetronomeTrackMixers] = useState([]);
- const [adhocTrackMixers, setAdhocTrackMixers] = useState([]);
- const [backingTracks, setBackingTracks] = useState([]);
- const [jamTracks, setJamTracks] = useState([]);
- const [recordedTracks, setRecordedTracks] = useState([]);
- const [metronome, setMetronome] = useState(null);
- const [mediaSummary, setMediaSummary] = useState({});
+ // Redux selectors - replace all useState calls
+ const chatMixer = useSelector(selectChatMixer);
+ const broadcastMixer = useSelector(selectBroadcastMixer);
+ const recordingMixer = useSelector(selectRecordingMixer);
+ const recordingTrackMixers = useSelector(selectRecordingTrackMixers);
+ const backingTrackMixers = useSelector(selectBackingTrackMixers);
+ const jamTrackMixers = useSelector(selectJamTrackMixers);
+ const metronomeTrackMixers = useSelector(selectMetronomeTrackMixers);
+ const adhocTrackMixers = useSelector(selectAdhocTrackMixers);
+ const masterMixers = useSelector(selectMasterMixers);
+ const personalMixers = useSelector(selectPersonalMixers);
+ const allMixers = useSelector(selectAllMixers);
+ const mixersByResourceId = useSelector(selectMixersByResourceId);
+ const mixersByTrackId = useSelector(selectMixersByTrackId);
+ const metronome = useSelector(selectMetronome);
+ const metronomeSettings = useSelector(selectMetronomeSettings);
+ const mediaSummary = useSelector(selectMediaSummary);
+ const noAudioUsers = useSelector(selectNoAudioUsers);
+ const clientsWithAudioOverride = useSelector(selectClientsWithAudioOverride);
+ const simulatedMusicCategoryMixers = useSelector(selectSimulatedMusicCategoryMixers);
+ const simulatedChatCategoryMixers = useSelector(selectSimulatedChatCategoryMixers);
+ const isReadyRedux = useSelector(selectMixersReady);
+ // Media data from mediaSlice
+ const backingTracks = useSelector(selectBackingTracks);
+ const jamTracks = useSelector(selectJamTracks);
+ const recordedTracks = useSelector(selectRecordedTracks);
+
+ // UI state from sessionUISlice
+ const mixMode = useSelector(selectMixMode);
+ const currentMixerRange = useSelector(selectCurrentMixerRange);
- const [session, setSession] = useState(null);
- const [masterMixers, setMasterMixers] = useState([]);
- const [personalMixers, setPersonalMixers] = useState([]);
- const [metro, setMetro] = useState(false);
- const [noAudioUsers, setNoAudioUsers] = useState([]);
- const [clientsWithAudioOverride, setClientsWithAudioOverride] = useState([]);
- const [mixMode, setMixMode] = useState(MIX_MODES.PERSONAL);
- const [mixersByResourceId, setMixersByResourceId] = useState({});
- const [mixersByTrackId, setMixersByTrackId] = useState({});
- const [allMixers, setAllMixers] = useState({});
- const [currentMixerRangeMin, setCurrentMixerRangeMin] = useState(null);
- const [currentMixerRangeMax, setCurrentMixerRangeMax] = useState(null);
const mediaTrackGroups = useMemo(() => [ChannelGroupIds.MediaTrackGroup, ChannelGroupIds.JamTrackGroup,
ChannelGroupIds.MetronomeGroup], []);
const muteBothMasterAndPersonalGroups = useMemo(() => [ChannelGroupIds.AudioInputMusicGroup, ChannelGroupIds.MidiInputMusicGroup, ChannelGroupIds.MediaTrackGroup,
ChannelGroupIds.JamTrackGroup, ChannelGroupIds.MetronomeGroup], []);
- const [simulatedMusicCategoryMixers, setSimulatedMusicCategoryMixers] = useState({});
- const [simulatedChatCategoryMixers, setSimulatedChatCategoryMixers] = useState({});
// Phase 4: Replace CurrentSessionContext with Redux
const currentSession = useSelector(selectActiveSession);
@@ -70,7 +119,10 @@ const useMixerHelper = () => {
}, [allMixers]);
const getMixer = (mixerId, mode) => {
- mode = mode || mixMode;
+ // Only default to mixMode if mode is undefined, not if it's explicitly false
+ if (mode === undefined) {
+ mode = mixMode;
+ }
return allMixersRef.current[(mode ? 'M' : 'P') + mixerId];
}
@@ -101,10 +153,14 @@ const useMixerHelper = () => {
loop: mixer.loop
});
- setCurrentMixerRangeMin(mixer.range_low);
- setCurrentMixerRangeMax(mixer.range_high);
+ // Redux: Update mixer range in sessionUISlice
+ dispatch(setCurrentMixerRange({
+ min: mixer.range_low,
+ max: mixer.range_high
+ }));
+
return mixer;
- }, [getMixer]);
+ }, [getMixer, setTrackVolumeObject, dispatch]);
const setMixerVolume = useCallback(async (mixer, volumePercent, relative, originalVolume, controlGroup) => {
const newVolume = faderHelpers.convertPercentToAudioTaper(volumePercent);
@@ -142,7 +198,7 @@ const useMixerHelper = () => {
console.log("setMixerVolume: setting session mixer volume for mixer", mixer.id, "mode", mixer.mode, "volume", updatedTrackVolumeObject.volL);
await jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, updatedTrackVolumeObject);
}
- }, [trackVolumeObject, faderHelpers, jamClient]);
+ }, [trackVolumeObject, faderHelpers, jamClient, setTrackVolumeObject]);
const mediaMixers = useCallback((masterMixer, isOpener, currentAllMixers) => {
const personalMixer = isOpener ? getMixerByResourceId(masterMixer.rid, MIX_MODES.PERSONAL, currentAllMixers) : masterMixer;
@@ -162,80 +218,32 @@ const useMixerHelper = () => {
};
}, []);
+ // Redux: updateMixerData now dispatches Redux actions
const updateMixerData = useCallback((session, masterMixers, personalMixers, metro, noAudioUsers, clientsWithAudioOverride, mixMode) => {
//console.debug("useMixerHelper: updateMixerData called", { session, masterMixers, personalMixers, mixMode });
- setSession(session); //sessin is set to sessionHelper
- setMasterMixers(masterMixers);
- setPersonalMixers(personalMixers);
- setMetro(metro);
- setNoAudioUsers(noAudioUsers);
- setClientsWithAudioOverride(clientsWithAudioOverride);
- setMixMode(mixMode);
- }, []);
+ // Dispatch Redux actions instead of setState calls
+ dispatch(setMasterMixers(masterMixers));
+ dispatch(setPersonalMixers(personalMixers));
+ // Note: session is tracked in activeSessionSlice, metro/noAudioUsers/clientsWithAudioOverride in mixersSlice
+ // mixMode is tracked in sessionUISlice
+ }, [dispatch]);
+ // Redux: organizeMixers is now a reducer, trigger it via action
useEffect(() => {
- if (!session || masterMixers.length === 0 || personalMixers.length === 0) return;
- organizeMixers();
- }, [session, masterMixers, personalMixers]);
+ if (!currentSession || masterMixers.length === 0 || personalMixers.length === 0) return;
+ dispatch(organizeMixers());
+ }, [currentSession, masterMixers, personalMixers, dispatch]);
- const organizeMixers = () => {
- const newAllMixers = {};
- const newMixersByResourceId = {};
- const newMixersByTrackId = {};
-
- for (const masterMixer of masterMixers) {
- newAllMixers['M' + masterMixer.id] = masterMixer;
-
- const mixerPair = {};
- newMixersByResourceId[masterMixer.rid] = mixerPair;
- newMixersByTrackId[masterMixer.id] = mixerPair;
- mixerPair.master = masterMixer;
- }
-
- for (const personalMixer of personalMixers) {
- newAllMixers['P' + personalMixer.id] = personalMixer;
-
- let mixerPair = newMixersByResourceId[personalMixer.rid];
- if (!mixerPair) {
- if (personalMixer.group_id !== ChannelGroupIds.MonitorGroup) {
- logger.warn("there is no master version of ", personalMixer);
- }
- mixerPair = {};
- newMixersByResourceId[personalMixer.rid] = mixerPair;
- }
- newMixersByTrackId[personalMixer.id] = mixerPair;
- mixerPair.personal = personalMixer;
- }
-
- //console.log("useMixerHelper: setting all mixers", newAllMixers);
- setAllMixers(prev => {
- return { ...prev, ...newAllMixers };
- });
- setMixersByResourceId(prev => {
- return { ...prev, ...newMixersByResourceId };
- });
- setMixersByTrackId(prev => {
- return { ...prev, ...newMixersByTrackId };
- });
-
- const newChatMixer = resolveChatMixer(newMixersByResourceId, newAllMixers);
- setChatMixer(newChatMixer);
-
-
- }
+ // Note: groupMixersByType logic is handled by WebSocket MIXER_CHANGES handler
+ // which updates backingTrackMixers, jamTrackMixers, etc. via Redux actions
+ // Sync local isReady ref with Redux isReady state
+ // This ensures VU meter callbacks have immediate access to the ready state
useEffect(() => {
- if (!session || Object.keys(allMixers).length === 0) return;
- groupTypes(allMixers);
- }, [session, allMixers]);
-
- useEffect(() => {
- if (Object.keys(allMixers).length > 0 && !isReady.current) {
- // console.log("useMixerHelper: isReady set to true");
- isReady.current = true;
- }
- }, [allMixers, isReady]);
+ isReady.current = isReadyRedux;
+ // console.log("useMixerHelper: isReady synced with Redux", isReadyRedux);
+ }, [isReadyRedux]);
const getMixerByTrackId = useCallback((trackId, mode) => {
const mixerPair = mixersByTrackId[trackId];
@@ -544,467 +552,7 @@ const useMixerHelper = () => {
const setMixerPan = useCallback(async (mixer, panPercent) => {
trackVolumeObject.pan = panHelpers.convertPercentToPan(panPercent);
await jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, trackVolumeObject);
- }, [trackVolumeObject, panHelpers]);
-
- const resolveBackingTracks = useCallback((currentBackingTrackMixers) => {
- const backingTracks = [];
-
- if (currentBackingTrackMixers.length === 0) return backingTracks;
-
- let serverBackingTracks = [];
- let backingTrackMixers = currentBackingTrackMixers;
-
- if (session.isPlayingRecording()) {
- backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => mixer.managed || !mixer.managed);
- serverBackingTracks = session.recordedBackingTracks();
- } else {
- serverBackingTracks = session.backingTracks();
- backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => !mixer.managed);
- if (backingTrackMixers.length > 1) {
- logger.error("multiple, managed backing track mixers encountered", backingTrackMixers);
- console.warn("Multiple Backing Tracks Encountered: Only one backing track can be open at a time.");
- return backingTracks;
- }
- }
-
- if (!serverBackingTracks || serverBackingTracks.length === 0) {
- return backingTracks;
- }
-
- let noCorrespondingTracks = false;
- for (const mixer of backingTrackMixers) {
- const correspondingTracks = [];
- noCorrespondingTracks = false;
- if (session.isPlayingRecording()) {
- for (const backingTrack of serverBackingTracks) {
- if (mixer.persisted_track_id === backingTrack.client_track_id || mixer.id === 'L' + backingTrack.client_track_id) {
- correspondingTracks.push(backingTrack);
- }
- }
- } else {
- correspondingTracks.push(serverBackingTracks[0]);
- }
-
- if (correspondingTracks.length === 0) {
- noCorrespondingTracks = true;
- logger.debug("renderBackingTracks: could not map backing tracks");
- console.warn("Unable to Open Backing Track: Could not correlate server and client tracks");
- break;
- }
-
- const serverBackingTrack = correspondingTracks[0];
-
- const oppositeMixer = getMixerByResourceId(mixer.rid, MIX_MODES.PERSONAL, allMixers);
-
- const isOpener = mixer.group_id === ChannelGroupIds.MediaTrackGroup;
- const data = {
- isOpener: isOpener,
- shortFilename: context.JK.getNameOfFile(serverBackingTrack.filename),
- instrumentIcon: getInstrumentIcon45(serverBackingTrack.instrument_id),
- photoUrl: "/assets/content/icon_recording.png",
- showLoop: isOpener && !session.isPlayingRecording(),
- track: serverBackingTrack,
- mixers: mediaMixers(mixer, isOpener, allMixers)
- };
-
- backingTracks.push(data);
- }
-
- return backingTracks;
- }, [session]);
-
- const resolveJamTracks = useCallback((currentJamTrackMixers) => {
- const _jamTracks = [];
-
- if (currentJamTrackMixers.length === 0) return _jamTracks;
-
- let jamTrackMixers = currentJamTrackMixers.slice();
- let jamTracks = [];
- let jamTrackName = null;
- const jamTrackMixdown = session?.jamTrackMixdown() || { id: null };
-
- if (session?.isPlayingRecording()) {
- jamTracks = session.recordedJamTracks();
- jamTrackName = session.recordedJamTrackName();
- } else {
- jamTracks = session?.jamTracks();
- jamTrackName = session?.jamTrackName();
- }
-
- const isOpener = jamTrackMixers[0]?.group_id === ChannelGroupIds.JamTrackGroup;
-
- if (jamTracks) {
- let noCorrespondingTracks = false;
-
- if (jamTrackMixdown.id) {
- logger.debug("MixerHelper: mixdown is active. id: #{jamTrackMixdown.id}");
- if (jamTrackMixers.length === 0) {
- noCorrespondingTracks = true;
- logger.error("could not correlate mixdown tracks", jamTrackMixers, jamTrackMixdown);
- // session?.app?.notify({
- // title: "Unable to Open Custom Mix",
- // text: "Could not correlate server and client tracks",
- // icon_url: "/assets/content/icon_alert_big.png"
- // });
- return _jamTracks;
- } else if (jamTrackMixers.length > 1) {
- logger.warn("ignoring wrong amount of mixers for JamTrack in mixdown mode");
- return _jamTracks;
- } else {
- const instrumentIcon = getInstrumentIcon24('other');
- const part = null;
- const instrumentName = 'Custom Mix';
- const trackName = 'Custom Mix';
-
- const data = {
- name: jamTrackName,
- trackName: trackName,
- part: part,
- isOpener: isOpener,
- instrumentIcon: instrumentIcon,
- track: jamTrackMixdown,
- mixers: mediaMixers(jamTrackMixers[0], isOpener, allMixers)
- };
-
- _jamTracks.push(data);
- }
- } else {
- logger.debug("MixerHelper: full jamtrack is active");
-
- if (jamTrackMixers.length === 1) {
- logger.warn("ignoring wrong amount of mixers for JamTrack in Full Track mode");
- return _jamTracks;
- }
-
- for (const jamTrack of jamTracks) {
- let mixer = null;
- const correspondingTracks = [];
-
- for (const matchMixer of currentJamTrackMixers) {
- if (matchMixer.id === jamTrack.id) {
- correspondingTracks.push(jamTrack);
- mixer = matchMixer;
- }
- }
-
- if (correspondingTracks.length === 0) {
- noCorrespondingTracks = true;
- logger.error("could not correlate jam tracks", jamTrackMixers, jamTracks);
- // session?.app?.notify({
- // title: "Unable to Open JamTrack",
- // text: "Could not correlate server and client tracks",
- // icon_url: "/assets/content/icon_alert_big.png"
- // });
- return _jamTracks;
- }
-
- jamTrackMixers.splice(jamTrackMixers.indexOf(mixer), 1);
-
- const oneOfTheTracks = correspondingTracks[0];
- const instrumentIcon = getInstrumentIcon24(oneOfTheTracks.instrument.id);
-
- const part = oneOfTheTracks.part;
-
- let instrumentName = oneOfTheTracks.instrument.description;
-
- let trackName;
- if (part) {
- trackName = `${instrumentName}: ${part}`;
- } else {
- trackName = instrumentName;
- }
-
- if (jamTrack.track_type === 'Click') {
- trackName = 'Clicktrack';
- }
-
- const data = {
- name: jamTrackName,
- trackName: trackName,
- part: part,
- isOpener: isOpener,
- instrumentIcon: instrumentIcon,
- track: oneOfTheTracks,
- mixers: mediaMixers(mixer, isOpener, allMixers)
- };
-
- _jamTracks.push(data);
- }
- }
- }
-
- return _jamTracks;
- }, [session, allMixers]);
-
- const resolveRecordedTracks = useCallback((currentRecordingTrackMixers, currentAllMixers) => {
- const recordedTracks = [];
-
- if (currentRecordingTrackMixers.length === 0) return recordedTracks;
-
- const serverRecordedTracks = session?.recordedTracks();
-
- const isOpener = currentRecordingTrackMixers[0]?.group_id === ChannelGroupIds.MediaTrackGroup;
-
- if (serverRecordedTracks) {
- const recordingName = session?.recordingName();
- let noCorrespondingTracks = false;
- for (const mixer of currentRecordingTrackMixers) {
- const correspondingTracks = [];
- for (const recordedTrack of serverRecordedTracks) {
- if (mixer.id.indexOf("L") === 0) {
- if (mixer.id.substring(1) === recordedTrack.client_track_id) {
- correspondingTracks.push(recordedTrack);
- }
- } else if (mixer.id.indexOf("C") === 0) {
- if (mixer.id.substring(1) === recordedTrack.client_id) {
- correspondingTracks.push(recordedTrack);
- }
- } else {
- alert("Invalid state: the recorded track had neither persisted_track_id or persisted_client_id");
- }
- }
-
- if (correspondingTracks.length === 0) {
- noCorrespondingTracks = true;
- // session?.app?.notify({
- // title: "Unable to Open Recording",
- // text: "Could not correlate server and client tracks",
- // icon_url: "/assets/content/icon_alert_big.png"
- // });
- return recordedTracks;
- }
-
- const oneOfTheTracks = correspondingTracks[0];
- const instrumentIcon = getInstrumentIcon24(oneOfTheTracks.instrument_id);
- let userName = oneOfTheTracks.user.name;
- if (!userName) {
- userName = oneOfTheTracks.user.first_name + ' ' + oneOfTheTracks.user.last_name;
- }
-
- const data = {
- recordingName: recordingName,
- isOpener: isOpener,
- userName: userName,
- instrumentIcon: instrumentIcon,
- track: oneOfTheTracks,
- mixers: mediaMixers(mixer, isOpener, currentAllMixers)
- };
-
- recordedTracks.push(data);
- }
- }
-
- return recordedTracks;
- }, [session]);
-
- const resolveMetronome = useCallback((currentMetronomeTrackMixers, currentAllMixers) => {
- if (currentMetronomeTrackMixers.length === 0) return null;
-
- const mixer = currentMetronomeTrackMixers[0];
-
- const instrumentIcon = "/assets/content/icon_metronome.png";
-
- const metronome = {
- instrumentIcon: instrumentIcon,
- mixers: mediaMixers(mixer, true, currentAllMixers)
- };
-
- return metronome;
- }, []);
-
- const resolveChatMixer = useCallback((currentMixersByResourceId, currentAllMixers) => {
- const masterChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.MASTER, currentAllMixers);
-
- if (masterChatMixers.length === 0) return null;
-
- const personalChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.PERSONAL, currentAllMixers);
-
- if (personalChatMixers.length === 0) {
- logger.warn("unable to find personal mixer for voice chat");
- return null;
- }
-
- const masterChatMixer = masterChatMixers[0];
- const personalChatMixer = personalChatMixers[0];
-
- return {
- master: {
- mixer: masterChatMixer,
- muteMixer: masterChatMixer,
- vuMixer: masterChatMixer,
- oppositeMixer: personalChatMixer
- },
- personal: {
- mixer: personalChatMixer,
- muteMixer: personalChatMixer,
- vuMixer: personalChatMixer,
- oppositeMixer: masterChatMixer
- }
- };
- }, []);
-
- const groupTypes = useCallback(() => {
- // console.debug("useMixerHelper: groupTypes called", {session, allMixers});
-
- if(!session || Object.keys(allMixers).length === 0) return;
-
-
- const localMediaMixers = mixersForGroupIds(mediaTrackGroups, MIX_MODES.MASTER, allMixers);
- const peerLocalMediaMixers = mixersForGroupId(ChannelGroupIds.PeerMediaTrackGroup, MIX_MODES.MASTER, allMixers);
-
- const newRecordingTrackMixers = [];
- const newBackingTrackMixers = [];
- const newJamTrackMixers = [];
- const newMetronomeTrackMixers = [];
- const newAdhocTrackMixers = [];
-
- const groupByType = (mixers, isLocalMixer) => {
- for (const mixer of mixers) {
- const mediaType = mixer.media_type;
- const groupId = mixer.group_id;
-
- if (mediaType === 'MetronomeTrack' || groupId === ChannelGroupIds.MetronomeGroup) {
- newMetronomeTrackMixers.push(mixer);
- } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
- let isJamTrack = false;
-
- if (mixer.id === session.jamTrackMixdown()?.id) {
- isJamTrack = true;
- }
-
- if (!isJamTrack && session.jamTracks()) {
- for (const jamTrack of session.jamTracks()) {
- if (mixer.id === jamTrack.id) {
- isJamTrack = true;
- break;
- }
- }
- }
-
- if (!isJamTrack && session.recordedJamTracks()) {
- for (const recordedJamTrack of session.recordedJamTracks()) {
- if (mixer.id === recordedJamTrack.id) {
- isJamTrack = true;
- break;
- }
- }
- }
-
- if (isJamTrack) {
- newJamTrackMixers.push(mixer);
- } else {
- let isBackingTrack = false;
- if (session.recordedBackingTracks()) {
- for (const recordedBackingTrack of session.recordedBackingTracks()) {
- if (mixer.id === 'L' + recordedBackingTrack.client_track_id) {
- isBackingTrack = true;
- break;
- }
- }
- }
-
- if (session.backingTracks()) {
- for (const backingTrack of session.backingTracks()) {
- if (mixer.id === 'L' + backingTrack.client_track_id) {
- isBackingTrack = true;
- break;
- }
- }
- }
-
- if (isBackingTrack) {
- newBackingTrackMixers.push(mixer);
- } else {
- newRecordingTrackMixers.push(mixer);
- }
- }
- } else if (mediaType === 'PeerMediaTrack' || mediaType === 'BackingTrack') {
- newBackingTrackMixers.push(mixer);
- } else if (mediaType === 'JamTrack') {
- newJamTrackMixers.push(mixer);
- } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
- newRecordingTrackMixers.push(mixer);
- } else {
- if (mediaType !== 'Broadcast') {
- logger.warn("Unknown track type: " + mediaType);
- newAdhocTrackMixers.push(mixer);
- }
- }
- }
- };
-
- groupByType(localMediaMixers, true);
- groupByType(peerLocalMediaMixers, false);
-
- setRecordingTrackMixers(newRecordingTrackMixers);
- setBackingTrackMixers(newBackingTrackMixers);
- setJamTrackMixers(newJamTrackMixers);
- setMetronomeTrackMixers(newMetronomeTrackMixers);
- setAdhocTrackMixers(newAdhocTrackMixers);
-
- const newBackingTracks = resolveBackingTracks(newBackingTrackMixers, allMixers);
- const newJamTracks = resolveJamTracks(newJamTrackMixers, allMixers);
- const newRecordedTracks = resolveRecordedTracks(newRecordingTrackMixers, allMixers);
- const newMetronome = resolveMetronome(newMetronomeTrackMixers, allMixers);
-
- setBackingTracks(newBackingTracks);
- setJamTracks(newJamTracks);
- setRecordedTracks(newRecordedTracks);
- setMetronome(newMetronome);
-
- const newMediaSummary = {
- recordingOpen: newRecordedTracks.length > 0,
- jamTrackOpen: newJamTracks.length > 0,
- backingTrackOpen: newBackingTracks.length > 0,
- metronomeOpen: session.isMetronomeOpen()
- };
-
- let mediaOpenSummary = false;
- for (const mediaType in newMediaSummary) {
- if (newMediaSummary[mediaType]) {
- mediaOpenSummary = true;
- break;
- }
- }
-
- newMediaSummary.mediaOpen = mediaOpenSummary;
- newMediaSummary.userNeedsMediaControls = newMediaSummary.mediaOpen || window.JamTrackStore?.jamTrack;
- newMediaSummary.jamTrack = window.JamTrackStore?.jamTrack;
-
- let isOpener = false;
- if (newMediaSummary.recordingOpen) {
- isOpener = newRecordedTracks[0].isOpener;
- } else if (newMediaSummary.jamTrackOpen) {
- isOpener = newJamTracks[0].isOpener;
- } else if (newMediaSummary.backingTrackOpen) {
- isOpener = newBackingTracks[0].isOpener;
- }
-
- newMediaSummary.isOpener = isOpener;
- setMediaSummary(newMediaSummary);
-
- prepareSimulatedMixers(allMixers);
- }, [session, allMixers]);
-
- const mixersForGroupIds = useCallback((groupIds, mixMode, currentAllMixers) => {
- const foundMixers = [];
- const modePrefix = mixMode === MIX_MODES.MASTER ? 'M' : 'P';
-
- for (const mixerKey in currentAllMixers) {
- if (mixerKey.startsWith(modePrefix)) {
- const mixer = currentAllMixers[mixerKey];
- if (mixer && groupIds.includes(mixer.group_id)) {
- foundMixers.push(mixer);
- }
- }
- }
-
- return foundMixers;
- }, []);
-
-
-
-
+ }, [trackVolumeObject, panHelpers, jamClient]);
const getMixerByResourceId = useCallback((resourceId, mode) => {
const mixerPair = mixersByResourceId[resourceId];
@@ -1022,8 +570,6 @@ const useMixerHelper = () => {
}
}, [mixersByResourceId]);
-
-
const mute = useCallback(async (mixerId, mode, muting) => {
if (mode == null) { mode = mixMode; }
@@ -1089,16 +635,16 @@ const useMixerHelper = () => {
await setMixerVolume(mixer, data.percentage, relative, originalVolume, controlGroup, allMixers);
- // Update local mixer state - create new state object to trigger React re-renders
- setAllMixers(prev => ({
- ...prev,
- [`${mixer.mode ? 'M' : 'P'}${mixer.id}`]: {
- ...prev[`${mixer.mode ? 'M' : 'P'}${mixer.id}`],
+ // Redux: Update local mixer state via dispatch
+ dispatch(updateMixer({
+ mixerId: mixer.id,
+ mode: mixer.mode,
+ updates: {
volume_left: trackVolumeObject.volL
}
}));
}
- }, [getOriginalVolume, getMixer, fillTrackVolumeObject, setMixerVolume, allMixers]);
+ }, [getOriginalVolume, getMixer, fillTrackVolumeObject, setMixerVolume, allMixers, trackVolumeObject, dispatch]);
const initGain = useCallback((mixer) => {
if (Array.isArray(mixer)) {
@@ -1125,13 +671,13 @@ const useMixerHelper = () => {
result.push(trackVolumeObject.pan);
}
return result;
- }, [getMixer, fillTrackVolumeObject, setMixerPan, allMixers]);
+ }, [getMixer, fillTrackVolumeObject, setMixerPan, allMixers, trackVolumeObject]);
const initPan = useCallback((mixer) => {
const panPercent = panHelpers.convertPanToPercent(mixer.pan);
faderHelpers.setFaderValue(mixer.id, panPercent, Math.abs(mixer.pan));
faderHelpers.showFader(mixer.id);
- }, [faderHelpers]);
+ }, [faderHelpers, panHelpers]);
const loopChanged = useCallback(async (mixer, shouldLoop) => {
fillTrackVolumeObject(mixer.id, mixer.mode, allMixers);
@@ -1144,7 +690,7 @@ const useMixerHelper = () => {
const updatedMixer = getMixer(mixer.id, mixer.mode, allMixers);
updatedMixer.loop = context.trackVolumeObject.loop;
- }, [getMixer, fillTrackVolumeObject, allMixers]);
+ }, [getMixer, fillTrackVolumeObject, allMixers, setTrackVolumeObject]);
const percentFromMixerValue = useCallback((min, max, value) => {
try {
@@ -1202,81 +748,26 @@ const useMixerHelper = () => {
// console.log("useMixerHelper: updateVU mixer", allMixersRef.current, mixerId, mode, mixer);
updateVU3(mixer, leftValue, leftClipping, rightValue, rightClipping);
}
- }, []);
+ }, [getMixer, updateVU3]);
const getTrackInfo = useCallback(async () => {
return await context.JK.TrackHelpers.getTrackInfo(context.jamClient, masterMixers);
}, [masterMixers]);
- const prepareSimulatedMixers = useCallback(() => {
- const newSimulatedMusicCategoryMixers = {};
- const newSimulatedChatCategoryMixers = {};
+ const mixersForGroupIds = useCallback((groupIds, mixMode, currentAllMixers) => {
+ const foundMixers = [];
+ const modePrefix = mixMode === MIX_MODES.MASTER ? 'M' : 'P';
- newSimulatedMusicCategoryMixers[MIX_MODES.MASTER] = getSimulatedMusicCategoryMixer(MIX_MODES.MASTER, allMixers);
- newSimulatedMusicCategoryMixers[MIX_MODES.PERSONAL] = getSimulatedMusicCategoryMixer(MIX_MODES.PERSONAL, allMixers);
-
- // Call getSimulatedChatCategoryMixer once and assign parts like web project
- const chatMixerData = getSimulatedChatCategoryMixer(allMixers);
- newSimulatedChatCategoryMixers[MIX_MODES.MASTER] = chatMixerData?.master;
- newSimulatedChatCategoryMixers[MIX_MODES.PERSONAL] = chatMixerData?.personal;
-
- setSimulatedMusicCategoryMixers(newSimulatedMusicCategoryMixers);
- setSimulatedChatCategoryMixers(newSimulatedChatCategoryMixers);
- }, [allMixers]);
-
- const getSimulatedMusicCategoryMixer = useCallback((mode, currentAllMixers) => {
- const myInputs = getAudioInputCategoryMixer(mode, currentAllMixers)?.mixer;
- const peerInputs = getUserMusicCategoryMixer(mode, currentAllMixers)?.mixer;
- const myMedia = getMediaCategoryMixer(mode, currentAllMixers)?.mixer;
- const peerMedia = getUserMediaCategoryMixer(mode, currentAllMixers)?.mixer;
- const metronome = getMetronomeCategoryMixer(mode, currentAllMixers)?.mixer;
- const output = getOutputMixer(mode, currentAllMixers);
- const oppositeOutput = getOutputMixer(!mode, currentAllMixers);
-
- if (myInputs) {
- return {
- first: myInputs,
- mixer: [myInputs, peerInputs, myMedia, peerMedia, metronome].filter(m => m),
- muteMixer: [myInputs, peerInputs, myMedia, peerMedia, metronome].filter(m => m),
- vuMixer: output
- };
- } else {
- return null;
- }
- }, [getAudioInputCategoryMixer, getUserMusicCategoryMixer, getMediaCategoryMixer, getUserMediaCategoryMixer, getMetronomeCategoryMixer, getOutputMixer]);
-
- const getSimulatedChatCategoryMixer = useCallback((currentAllMixers) => {
- // Find chat mixers by group_id like the web project does
-
- const masterChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.MASTER, currentAllMixers);
- const personalChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.PERSONAL, currentAllMixers);
-
-
-
- if (masterChatMixers.length === 0 || personalChatMixers.length === 0) {
- return null;
- }
-
- const masterChatMixer = masterChatMixers[0];
- const personalChatMixer = personalChatMixers[0];
-
- console.debug("useMixerHelper: getSimulatedChatCategoryMixer", masterChatMixer, personalChatMixer);
-
- // Return the same structure as web project's resolveChatMixer
- return {
- master: {
- mixer: masterChatMixer,
- muteMixer: masterChatMixer,
- vuMixer: masterChatMixer,
- oppositeMixer: personalChatMixer
- },
- personal: {
- mixer: personalChatMixer,
- muteMixer: personalChatMixer,
- vuMixer: personalChatMixer,
- oppositeMixer: masterChatMixer
+ for (const mixerKey in currentAllMixers) {
+ if (mixerKey.startsWith(modePrefix)) {
+ const mixer = currentAllMixers[mixerKey];
+ if (mixer && groupIds.includes(mixer.group_id)) {
+ foundMixers.push(mixer);
+ }
}
- };
+ }
+
+ return foundMixers;
}, []);
const refreshMixer = useCallback((mixers, currentAllMixers) => {
@@ -1329,17 +820,17 @@ const useMixerHelper = () => {
}, [getMixer]);
const recordingName = useCallback(() => {
- return session.recordingName();
- }, [session]);
+ return currentSession.recordingName();
+ }, [currentSession]);
const jamTrackName = useCallback(() => {
- return session.jamTrackName();
- }, [session]);
+ return currentSession.jamTrackName();
+ }, [currentSession]);
return {
isReady,
- session,
+ session: currentSession, // Return currentSession from Redux instead of local state
// masterMixers,
// personalMixers,
mixers: allMixers,
diff --git a/jam-ui/src/hooks/useMixerHelper.old.js b/jam-ui/src/hooks/useMixerHelper.old.js
new file mode 100644
index 000000000..45d4cbbb7
--- /dev/null
+++ b/jam-ui/src/hooks/useMixerHelper.old.js
@@ -0,0 +1,1359 @@
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import { selectActiveSession, selectInSession } from '../store/features/activeSessionSlice';
+import { ChannelGroupIds, CategoryGroupIds, MIX_MODES, MIDI_TRACK } from '../helpers/globals.js';
+import useVuHelpers from './useVuHelpers.js';
+import useFaderHelpers from './useFaderHelpers.js';
+import useSessionHelper from './useSessionHelper.js';
+import usePanHelpers from './usePanHelpers.js';
+import { useJamServerContext } from '../context/JamServerContext.js';
+import { useGlobalContext } from '../context/GlobalContext.js';
+import { useVuContext } from '../context/VuContext.js';
+import { getAvatarUrl, getInstrumentIcon45, getInstrumentIcon24 } from '../helpers/utils.js';
+
+
+const useMixerHelper = () => {
+ const allMixersRef = useRef({});
+
+ const [chatMixer, setChatMixer] = useState(null);
+ const [broadcastMixer, setBroadcastMixer] = useState(null);
+ const [recordingMixer, setRecordingMixer] = useState(null);
+ const [recordingTrackMixers, setRecordingTrackMixers] = useState([]);
+ const [backingTrackMixers, setBackingTrackMixers] = useState([]);
+ const [jamTrackMixers, setJamTrackMixers] = useState([]);
+ const [metronomeTrackMixers, setMetronomeTrackMixers] = useState([]);
+ const [adhocTrackMixers, setAdhocTrackMixers] = useState([]);
+ const [backingTracks, setBackingTracks] = useState([]);
+ const [jamTracks, setJamTracks] = useState([]);
+ const [recordedTracks, setRecordedTracks] = useState([]);
+ const [metronome, setMetronome] = useState(null);
+ const [mediaSummary, setMediaSummary] = useState({});
+
+
+ const [session, setSession] = useState(null);
+ const [masterMixers, setMasterMixers] = useState([]);
+ const [personalMixers, setPersonalMixers] = useState([]);
+ const [metro, setMetro] = useState(false);
+ const [noAudioUsers, setNoAudioUsers] = useState([]);
+ const [clientsWithAudioOverride, setClientsWithAudioOverride] = useState([]);
+ const [mixMode, setMixMode] = useState(MIX_MODES.PERSONAL);
+ const [mixersByResourceId, setMixersByResourceId] = useState({});
+ const [mixersByTrackId, setMixersByTrackId] = useState({});
+ const [allMixers, setAllMixers] = useState({});
+ const [currentMixerRangeMin, setCurrentMixerRangeMin] = useState(null);
+ const [currentMixerRangeMax, setCurrentMixerRangeMax] = useState(null);
+ const mediaTrackGroups = useMemo(() => [ChannelGroupIds.MediaTrackGroup, ChannelGroupIds.JamTrackGroup,
+ ChannelGroupIds.MetronomeGroup], []);
+ const muteBothMasterAndPersonalGroups = useMemo(() => [ChannelGroupIds.AudioInputMusicGroup, ChannelGroupIds.MidiInputMusicGroup, ChannelGroupIds.MediaTrackGroup,
+ ChannelGroupIds.JamTrackGroup, ChannelGroupIds.MetronomeGroup], []);
+ const [simulatedMusicCategoryMixers, setSimulatedMusicCategoryMixers] = useState({});
+ const [simulatedChatCategoryMixers, setSimulatedChatCategoryMixers] = useState({});
+
+ // Phase 4: Replace CurrentSessionContext with Redux
+ const currentSession = useSelector(selectActiveSession);
+ const inSession = useSelector(selectInSession);
+ const { jamClient, isConnected, server } = useJamServerContext();
+ const { updateVU3 } = useVuContext();
+ const { getParticipant } = useSessionHelper();
+ const { trackVolumeObject, setTrackVolumeObject } = useGlobalContext();
+ const faderHelpers = useFaderHelpers();
+ const panHelpers = usePanHelpers();
+
+ const logger = console; // for now
+ const context = window;
+
+ const isReady = useRef(false);
+
+ useEffect(() => {
+ allMixersRef.current = allMixers;
+ // console.log("_XDEBUG_ useMixerHelper: allMixersRef updated", allMixersRef.current);
+ }, [allMixers]);
+
+ const getMixer = (mixerId, mode) => {
+ mode = mode || mixMode;
+ return allMixersRef.current[(mode ? 'M' : 'P') + mixerId];
+ }
+
+ const fillTrackVolumeObject = useCallback((mixerId, mode, currentAllMixers, broadcast = true) => {
+ const mixer = getMixer(mixerId, mode, currentAllMixers);
+ if (mixer == null) {
+ console.error("MixerHelper: fillTrackVolumeObject: unable to find mixer with ID: #{mixerId}, mode: #{mode}");
+ return null;
+ }
+
+ setTrackVolumeObject({
+ id: mixer.id,
+ _id: mixer._id,
+ groupID: mixer.group_id,
+ clientID: mixer.client_id,
+ broadcast: broadcast,
+ master: mixer.master,
+ monitor: mixer.monitor,
+ mute: mixer.mute,
+ name: mixer.name,
+ record: mixer.record,
+ volL: mixer.volume_left,
+ pan: mixer.pan,
+ mediaType: mixer.media_type,
+ isJamTrack: mixer.is_jam_track,
+ isMetronome: mixer.is_metronome,
+ volR: mixer.volume_left,
+ loop: mixer.loop
+ });
+
+ setCurrentMixerRangeMin(mixer.range_low);
+ setCurrentMixerRangeMax(mixer.range_high);
+ return mixer;
+ }, [getMixer]);
+
+ const setMixerVolume = useCallback(async (mixer, volumePercent, relative, originalVolume, controlGroup) => {
+ const newVolume = faderHelpers.convertPercentToAudioTaper(volumePercent);
+
+ let updatedTrackVolumeObject;
+ if (relative) {
+ updatedTrackVolumeObject = {
+ ...trackVolumeObject,
+ volL: trackVolumeObject.volL + (newVolume - originalVolume),
+ volR: trackVolumeObject.volR + (newVolume - originalVolume),
+ };
+
+ // Apply clamping
+ if (updatedTrackVolumeObject.volL < -80) updatedTrackVolumeObject.volL = -80;
+ if (updatedTrackVolumeObject.volL > 20) updatedTrackVolumeObject.volL = 20;
+ if (updatedTrackVolumeObject.volR < -80) updatedTrackVolumeObject.volR = -80;
+ if (updatedTrackVolumeObject.volR > 20) updatedTrackVolumeObject.volR = 20;
+ } else {
+ updatedTrackVolumeObject = {
+ ...trackVolumeObject,
+ volL: newVolume,
+ volR: newVolume,
+ };
+ }
+
+ // Update state
+ setTrackVolumeObject(updatedTrackVolumeObject);
+
+ // Use the computed object for jamClient call
+ if (controlGroup != null) {
+ const controlGroupsArg = mixer.mode === MIX_MODES.PERSONAL ? 0 : 1;
+ console.log("setMixerVolume: setting session mixer category playout state for controlGroup", controlGroup, "controlGroupsArg", controlGroupsArg, "volume", updatedTrackVolumeObject.volL);
+ await jamClient.setSessionMixerCategoryPlayoutState(controlGroup === 'music', controlGroupsArg, updatedTrackVolumeObject.volL);
+ } else {
+ console.log("setMixerVolume: setting session mixer volume for mixer", mixer.id, "mode", mixer.mode, "volume", updatedTrackVolumeObject.volL);
+ await jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, updatedTrackVolumeObject);
+ }
+ }, [trackVolumeObject, faderHelpers, jamClient]);
+
+ const mediaMixers = useCallback((masterMixer, isOpener, currentAllMixers) => {
+ const personalMixer = isOpener ? getMixerByResourceId(masterMixer.rid, MIX_MODES.PERSONAL, currentAllMixers) : masterMixer;
+ const personalVuMixer = isOpener ? personalMixer : masterMixer;
+ return {
+ isOpener: isOpener,
+ master: {
+ mixer: masterMixer,
+ muteMixer: masterMixer,
+ vuMixer: masterMixer
+ },
+ personal: {
+ mixer: personalMixer,
+ muteMixer: personalMixer,
+ vuMixer: personalVuMixer
+ }
+ };
+ }, []);
+
+ const updateMixerData = useCallback((session, masterMixers, personalMixers, metro, noAudioUsers, clientsWithAudioOverride, mixMode) => {
+ //console.debug("useMixerHelper: updateMixerData called", { session, masterMixers, personalMixers, mixMode });
+
+ setSession(session); //sessin is set to sessionHelper
+ setMasterMixers(masterMixers);
+ setPersonalMixers(personalMixers);
+ setMetro(metro);
+ setNoAudioUsers(noAudioUsers);
+ setClientsWithAudioOverride(clientsWithAudioOverride);
+ setMixMode(mixMode);
+ }, []);
+
+ useEffect(() => {
+ if (!session || masterMixers.length === 0 || personalMixers.length === 0) return;
+ organizeMixers();
+ }, [session, masterMixers, personalMixers]);
+
+ const organizeMixers = () => {
+ const newAllMixers = {};
+ const newMixersByResourceId = {};
+ const newMixersByTrackId = {};
+
+ for (const masterMixer of masterMixers) {
+ newAllMixers['M' + masterMixer.id] = masterMixer;
+
+ const mixerPair = {};
+ newMixersByResourceId[masterMixer.rid] = mixerPair;
+ newMixersByTrackId[masterMixer.id] = mixerPair;
+ mixerPair.master = masterMixer;
+ }
+
+ for (const personalMixer of personalMixers) {
+ newAllMixers['P' + personalMixer.id] = personalMixer;
+
+ let mixerPair = newMixersByResourceId[personalMixer.rid];
+ if (!mixerPair) {
+ if (personalMixer.group_id !== ChannelGroupIds.MonitorGroup) {
+ logger.warn("there is no master version of ", personalMixer);
+ }
+ mixerPair = {};
+ newMixersByResourceId[personalMixer.rid] = mixerPair;
+ }
+ newMixersByTrackId[personalMixer.id] = mixerPair;
+ mixerPair.personal = personalMixer;
+ }
+
+ //console.log("useMixerHelper: setting all mixers", newAllMixers);
+ setAllMixers(prev => {
+ return { ...prev, ...newAllMixers };
+ });
+ setMixersByResourceId(prev => {
+ return { ...prev, ...newMixersByResourceId };
+ });
+ setMixersByTrackId(prev => {
+ return { ...prev, ...newMixersByTrackId };
+ });
+
+ const newChatMixer = resolveChatMixer(newMixersByResourceId, newAllMixers);
+ setChatMixer(newChatMixer);
+
+
+ }
+
+ useEffect(() => {
+ if (!session || Object.keys(allMixers).length === 0) return;
+ groupTypes(allMixers);
+ }, [session, allMixers]);
+
+ useEffect(() => {
+ if (Object.keys(allMixers).length > 0 && !isReady.current) {
+ // console.log("useMixerHelper: isReady set to true");
+ isReady.current = true;
+ }
+ }, [allMixers, isReady]);
+
+ const getMixerByTrackId = useCallback((trackId, mode) => {
+ const mixerPair = mixersByTrackId[trackId];
+
+ if (!mixerPair) return null;
+
+ if (mode === undefined) {
+ return mixerPair;
+ } else {
+ if (mode === MIX_MODES.MASTER) {
+ return mixerPair.master;
+ } else {
+ return mixerPair.personal;
+ }
+ }
+ }, [mixersByTrackId]);
+
+ const groupedMixersForClientId = useCallback((clientId, groupIds, usedMixers, mixMode, currentAllMixers) => {
+ const foundMixers = {};
+ const mixers = mixMode === MIX_MODES.MASTER ? masterMixers : personalMixers;
+
+ for (const mixer of mixers) {
+ if (!mixer) {
+ continue;
+ }
+
+ if (mixer.client_id === clientId) {
+ for (const groupId of groupIds) {
+ if (mixer.group_id === groupId) {
+ if ((mixer.groupId !== ChannelGroupIds.UserMusicInputGroup) && !(mixer.id in usedMixers)) {
+ let mixers = foundMixers[mixer.group_id];
+ if (!mixers) {
+ mixers = [];
+ foundMixers[mixer.group_id] = mixers;
+ }
+ mixers.push(mixer);
+ }
+ }
+ }
+ }
+ }
+
+ return foundMixers;
+ }, [masterMixers, personalMixers]);
+
+ const findMixerForTrack = useCallback((client_id, track, myTrack, mode) => {
+ let mixer = null;
+ let oppositeMixer = null;
+ let vuMixer = null;
+ let muteMixer = null;
+
+ if (myTrack) {
+ mixer = getMixerByTrackId(track.client_track_id, mode);
+ vuMixer = mixer;
+ muteMixer = mixer;
+
+ if (mixer && (mixer.group_id !== ChannelGroupIds.AudioInputMusicGroup && mixer.group_id !== ChannelGroupIds.MidiInputMusicGroup)) {
+ logger.error("found local mixer that was not of groupID: AudioInputMusicGroup", mixer);
+ }
+
+ if (mixer) {
+ oppositeMixer = getMixerByTrackId(track.client_track_id, !mode);
+
+ if (mode === MIX_MODES.PERSONAL) {
+ muteMixer = oppositeMixer;
+ }
+
+ if (!oppositeMixer) {
+ logger.error("unable to find opposite mixer for local mixer", mixer);
+ } else if (oppositeMixer.group_id !== ChannelGroupIds.AudioInputMusicGroup && oppositeMixer.group_id !== ChannelGroupIds.MidiInputMusicGroup) {
+ logger.error("found local mixer in opposite mode that was not of groupID: AudioInputMusicGroup", mixer, oppositeMixer);
+ }
+ } else {
+ logger.debug("local track is not present: ", track, allMixers);
+ }
+ } else {
+ switch (mode) {
+ case MIX_MODES.MASTER:
+ mixer = getMixerByTrackId(track.client_track_id, MIX_MODES.MASTER);
+
+ if (mixer && (mixer.group_id !== ChannelGroupIds.PeerAudioInputMusicGroup && mixer.group_id !== ChannelGroupIds.PeerMidiInputMusicGroup)) {
+ logger.warn("master: found remote mixer that was not of groupID: PeerAudioInputMusicGroup", client_id, track.client_track_id, mixer);
+ }
+
+ vuMixer = mixer;
+ muteMixer = mixer;
+
+ if (mixer) {
+ const oppositeMixers = groupedMixersForClientId(client_id, [ChannelGroupIds.UserMusicInputGroup], {}, MIX_MODES.PERSONAL);
+ if (oppositeMixers[ChannelGroupIds.UserMusicInputGroup]) {
+ oppositeMixer = oppositeMixers[ChannelGroupIds.UserMusicInputGroup][0];
+ }
+
+ if (!oppositeMixer) {
+ logger.warn("unable to find UserMusicInputGroup corresponding to PeerAudioInputMusicGroup mixer", mixer, personalMixers);
+ }
+ }
+ break;
+
+ case MIX_MODES.PERSONAL:
+ const mixers = groupedMixersForClientId(client_id, [ChannelGroupIds.UserMusicInputGroup], {}, MIX_MODES.PERSONAL);
+ if (mixers[ChannelGroupIds.UserMusicInputGroup]) {
+ mixer = mixers[ChannelGroupIds.UserMusicInputGroup][0];
+
+ vuMixer = mixer;
+ muteMixer = mixer;
+
+ if (mixer) {
+ oppositeMixer = getMixerByTrackId(track.client_track_id, MIX_MODES.MASTER);
+ if (!oppositeMixer) {
+ logger.debug("personal: unable to find a PeerAudioInputMusicGroup master mixer matching a UserMusicInput", client_id, track.client_track_id);
+ } else if (oppositeMixer.group_id !== ChannelGroupIds.PeerAudioInputMusicGroup && oppositeMixer.group_id !== ChannelGroupIds.PeerMidiInputMusicGroup) {
+ logger.error("personal: found remote mixer that was not of groupID: PeerAudioInputMusicGroup", client_id, track.client_track_id, mixer);
+ }
+ }
+ } else {
+ logger.error("no UserMusicInputGroup for client_id #{client_id} in PERSONAL mode", mixers);
+ }
+ break;
+ }
+ }
+
+ return {
+ mixer: mixer,
+ oppositeMixer: oppositeMixer,
+ vuMixer: vuMixer,
+ muteMixer: muteMixer
+ };
+ }, [getMixerByTrackId, groupedMixersForClientId, personalMixers, allMixers]);
+
+ // Compute myTracks - memoized to prevent infinite re-renders
+ const myTracks = useMemo(() => {
+ //console.debug("useMixerHelper: computing myTracks", { isConnected, currentSession, jamClient, allMixers });
+
+ if (!isConnected || !inSession || !jamClient || !allMixers) return [];
+
+ //const participant = currentSession.participants?.[jamClient.clientId];
+ const participant = getParticipant(server.clientId);
+
+ if (!participant) return [];
+
+ const tracks = [];
+ const connStatsClientId = participant.client_role === 'child' && participant.parent_client_id
+ ? participant.parent_client_id
+ : server.clientId;
+
+ console.debug("useMixerHelper: my participant", participant, { connStatsClientId });
+
+ const photoUrl = getAvatarUrl(participant.user.photo_url);
+ const name = participant.user.name;
+
+ // Get VST track assignments
+ let vstTrackAssignments = { vsts: [] };
+ try {
+ const assignments = jamClient.VSTListTrackAssignments();
+ vstTrackAssignments = assignments || { vsts: [] };
+ } catch (error) {
+ console.warn("Failed to get VST track assignments:", error);
+ }
+
+ for (const track of participant.tracks || []) {
+ const mixerData = findMixerForTrack(participant.client_id, track, true, mixMode);
+ const hasMixer = !!mixerData.mixer;
+
+ const instrumentIcon = getInstrumentIcon45(track.instrument_id || track.instrument);
+ const trackName = name;
+
+ // Check if this track has a VST plugin assigned
+ const hasVst = vstTrackAssignments.vsts && Array.isArray(vstTrackAssignments.vsts)
+ ? vstTrackAssignments.vsts.some(vst => vst.track === track.client_track_id)
+ : false;
+
+ tracks.push({
+ track: {
+ ...track,
+ hasVst
+ },
+ mixerFinder: [participant.client_id, track, true],
+ mixers: mixerData,
+ hasMixer,
+ name,
+ trackName,
+ instrumentIcon,
+ photoUrl,
+ clientId: participant.client_id,
+ connStatsClientId
+ });
+ }
+
+ return tracks;
+ }, [currentSession, isConnected, jamClient, allMixers, mixMode, findMixerForTrack, getParticipant, server.clientId]);
+
+ // useEffect(() => {
+ // console.debug("useMixerHelper: myTracks updated", myTracks);
+ // }, [myTracks]);
+
+
+ const mixersForGroupId = useCallback((groupId, mixMode, currentAllMixers) => {
+ const foundMixers = [];
+ const modePrefix = mixMode === MIX_MODES.MASTER ? 'M' : 'P';
+
+ for (const mixerKey in currentAllMixers) {
+ if (mixerKey.startsWith(modePrefix)) {
+ const mixer = currentAllMixers[mixerKey];
+ if (mixer && mixer.group_id === groupId) {
+ foundMixers.push(mixer);
+ }
+ }
+ }
+
+ return foundMixers;
+ }, []);
+
+ const getGroupMixer = useCallback((categoryId, mode) => {
+ const groupId = mode === MIX_MODES.MASTER ? ChannelGroupIds.MasterCatGroup : ChannelGroupIds.MonitorCatGroup;
+ const oppositeGroupId = !mode === MIX_MODES.MASTER ? ChannelGroupIds.MasterCatGroup : ChannelGroupIds.MonitorCatGroup;
+ const mixers = mixersForGroupId(groupId, mode, allMixers);
+ const oppositeMixers = mixersForGroupId(oppositeGroupId, !mode, allMixers);
+
+ if (mixers.length === 0) {
+ return null;
+ }
+
+ let found = null;
+ let oppositeFound = null;
+ for (const mixer of mixers) {
+ if (mixer.name === categoryId) {
+ found = mixer;
+ break;
+ }
+ }
+
+ for (const mixer of oppositeMixers) {
+ if (mixer.name === categoryId) {
+ oppositeFound = mixer;
+ break;
+ }
+ }
+
+ if (!found) {
+ logger.warn("could not find mixer with categoryId: " + categoryId);
+ return null;
+ } else {
+ return {
+ mixer: found,
+ muteMixer: found,
+ vuMixer: found,
+ oppositeMixer: oppositeFound
+ };
+ }
+ }, [mixersForGroupId, allMixers]);
+
+ const mixerForGroupId = useCallback((groupId, mixMode, currentAllMixers) => {
+ const mixers = mixersForGroupId(groupId, mixMode, currentAllMixers);
+ if (mixers && mixers.length > 0) {
+ return mixers[0];
+ } else {
+ return null;
+ }
+ }, [mixersForGroupId]);
+
+ const getAudioInputCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.AudioInputMusic, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getChatCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.AudioInputChat, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getUserChatCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.UserChat, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getMediaCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.MediaTrack, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getUserMediaCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.UserMedia, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getUserMusicCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.UserMusic, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getMetronomeCategoryMixer = useCallback((mode, currentAllMixers) => {
+ return getGroupMixer(CategoryGroupIds.Metronome, mode, currentAllMixers);
+ }, [getGroupMixer]);
+
+ const getOutputCategoryMixer = useCallback((mode, currentAllMixers) => {
+ if (mode === MIX_MODES.MASTER) {
+ return getGroupMixer(CategoryGroupIds.MasterCatGroup, mode, currentAllMixers);
+ } else {
+ return getGroupMixer(CategoryGroupIds.MonitorCatGroup, mode, currentAllMixers);
+ }
+ }, [getGroupMixer]);
+
+ const getOutputMixer = useCallback((mode, currentAllMixers) => {
+ if (mode === MIX_MODES.MASTER) {
+ return mixerForGroupId(ChannelGroupIds.MasterGroup, mode, currentAllMixers);
+ } else {
+ return mixerForGroupId(ChannelGroupIds.MonitorGroup, mode, currentAllMixers);
+ }
+ }, [mixerForGroupId]);
+
+ const setMixerPan = useCallback(async (mixer, panPercent) => {
+ trackVolumeObject.pan = panHelpers.convertPercentToPan(panPercent);
+ await jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, trackVolumeObject);
+ }, [trackVolumeObject, panHelpers]);
+
+ const resolveBackingTracks = useCallback((currentBackingTrackMixers) => {
+ const backingTracks = [];
+
+ if (currentBackingTrackMixers.length === 0) return backingTracks;
+
+ let serverBackingTracks = [];
+ let backingTrackMixers = currentBackingTrackMixers;
+
+ if (session.isPlayingRecording()) {
+ backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => mixer.managed || !mixer.managed);
+ serverBackingTracks = session.recordedBackingTracks();
+ } else {
+ serverBackingTracks = session.backingTracks();
+ backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => !mixer.managed);
+ if (backingTrackMixers.length > 1) {
+ logger.error("multiple, managed backing track mixers encountered", backingTrackMixers);
+ console.warn("Multiple Backing Tracks Encountered: Only one backing track can be open at a time.");
+ return backingTracks;
+ }
+ }
+
+ if (!serverBackingTracks || serverBackingTracks.length === 0) {
+ return backingTracks;
+ }
+
+ let noCorrespondingTracks = false;
+ for (const mixer of backingTrackMixers) {
+ const correspondingTracks = [];
+ noCorrespondingTracks = false;
+ if (session.isPlayingRecording()) {
+ for (const backingTrack of serverBackingTracks) {
+ if (mixer.persisted_track_id === backingTrack.client_track_id || mixer.id === 'L' + backingTrack.client_track_id) {
+ correspondingTracks.push(backingTrack);
+ }
+ }
+ } else {
+ correspondingTracks.push(serverBackingTracks[0]);
+ }
+
+ if (correspondingTracks.length === 0) {
+ noCorrespondingTracks = true;
+ logger.debug("renderBackingTracks: could not map backing tracks");
+ console.warn("Unable to Open Backing Track: Could not correlate server and client tracks");
+ break;
+ }
+
+ const serverBackingTrack = correspondingTracks[0];
+
+ const oppositeMixer = getMixerByResourceId(mixer.rid, MIX_MODES.PERSONAL, allMixers);
+
+ const isOpener = mixer.group_id === ChannelGroupIds.MediaTrackGroup;
+ const data = {
+ isOpener: isOpener,
+ shortFilename: context.JK.getNameOfFile(serverBackingTrack.filename),
+ instrumentIcon: getInstrumentIcon45(serverBackingTrack.instrument_id),
+ photoUrl: "/assets/content/icon_recording.png",
+ showLoop: isOpener && !session.isPlayingRecording(),
+ track: serverBackingTrack,
+ mixers: mediaMixers(mixer, isOpener, allMixers)
+ };
+
+ backingTracks.push(data);
+ }
+
+ return backingTracks;
+ }, [session]);
+
+ const resolveJamTracks = useCallback((currentJamTrackMixers) => {
+ const _jamTracks = [];
+
+ if (currentJamTrackMixers.length === 0) return _jamTracks;
+
+ let jamTrackMixers = currentJamTrackMixers.slice();
+ let jamTracks = [];
+ let jamTrackName = null;
+ const jamTrackMixdown = session?.jamTrackMixdown() || { id: null };
+
+ if (session?.isPlayingRecording()) {
+ jamTracks = session.recordedJamTracks();
+ jamTrackName = session.recordedJamTrackName();
+ } else {
+ jamTracks = session?.jamTracks();
+ jamTrackName = session?.jamTrackName();
+ }
+
+ const isOpener = jamTrackMixers[0]?.group_id === ChannelGroupIds.JamTrackGroup;
+
+ if (jamTracks) {
+ let noCorrespondingTracks = false;
+
+ if (jamTrackMixdown.id) {
+ logger.debug("MixerHelper: mixdown is active. id: #{jamTrackMixdown.id}");
+ if (jamTrackMixers.length === 0) {
+ noCorrespondingTracks = true;
+ logger.error("could not correlate mixdown tracks", jamTrackMixers, jamTrackMixdown);
+ // session?.app?.notify({
+ // title: "Unable to Open Custom Mix",
+ // text: "Could not correlate server and client tracks",
+ // icon_url: "/assets/content/icon_alert_big.png"
+ // });
+ return _jamTracks;
+ } else if (jamTrackMixers.length > 1) {
+ logger.warn("ignoring wrong amount of mixers for JamTrack in mixdown mode");
+ return _jamTracks;
+ } else {
+ const instrumentIcon = getInstrumentIcon24('other');
+ const part = null;
+ const instrumentName = 'Custom Mix';
+ const trackName = 'Custom Mix';
+
+ const data = {
+ name: jamTrackName,
+ trackName: trackName,
+ part: part,
+ isOpener: isOpener,
+ instrumentIcon: instrumentIcon,
+ track: jamTrackMixdown,
+ mixers: mediaMixers(jamTrackMixers[0], isOpener, allMixers)
+ };
+
+ _jamTracks.push(data);
+ }
+ } else {
+ logger.debug("MixerHelper: full jamtrack is active");
+
+ if (jamTrackMixers.length === 1) {
+ logger.warn("ignoring wrong amount of mixers for JamTrack in Full Track mode");
+ return _jamTracks;
+ }
+
+ for (const jamTrack of jamTracks) {
+ let mixer = null;
+ const correspondingTracks = [];
+
+ for (const matchMixer of currentJamTrackMixers) {
+ if (matchMixer.id === jamTrack.id) {
+ correspondingTracks.push(jamTrack);
+ mixer = matchMixer;
+ }
+ }
+
+ if (correspondingTracks.length === 0) {
+ noCorrespondingTracks = true;
+ logger.error("could not correlate jam tracks", jamTrackMixers, jamTracks);
+ // session?.app?.notify({
+ // title: "Unable to Open JamTrack",
+ // text: "Could not correlate server and client tracks",
+ // icon_url: "/assets/content/icon_alert_big.png"
+ // });
+ return _jamTracks;
+ }
+
+ jamTrackMixers.splice(jamTrackMixers.indexOf(mixer), 1);
+
+ const oneOfTheTracks = correspondingTracks[0];
+ const instrumentIcon = getInstrumentIcon24(oneOfTheTracks.instrument.id);
+
+ const part = oneOfTheTracks.part;
+
+ let instrumentName = oneOfTheTracks.instrument.description;
+
+ let trackName;
+ if (part) {
+ trackName = `${instrumentName}: ${part}`;
+ } else {
+ trackName = instrumentName;
+ }
+
+ if (jamTrack.track_type === 'Click') {
+ trackName = 'Clicktrack';
+ }
+
+ const data = {
+ name: jamTrackName,
+ trackName: trackName,
+ part: part,
+ isOpener: isOpener,
+ instrumentIcon: instrumentIcon,
+ track: oneOfTheTracks,
+ mixers: mediaMixers(mixer, isOpener, allMixers)
+ };
+
+ _jamTracks.push(data);
+ }
+ }
+ }
+
+ return _jamTracks;
+ }, [session, allMixers]);
+
+ const resolveRecordedTracks = useCallback((currentRecordingTrackMixers, currentAllMixers) => {
+ const recordedTracks = [];
+
+ if (currentRecordingTrackMixers.length === 0) return recordedTracks;
+
+ const serverRecordedTracks = session?.recordedTracks();
+
+ const isOpener = currentRecordingTrackMixers[0]?.group_id === ChannelGroupIds.MediaTrackGroup;
+
+ if (serverRecordedTracks) {
+ const recordingName = session?.recordingName();
+ let noCorrespondingTracks = false;
+ for (const mixer of currentRecordingTrackMixers) {
+ const correspondingTracks = [];
+ for (const recordedTrack of serverRecordedTracks) {
+ if (mixer.id.indexOf("L") === 0) {
+ if (mixer.id.substring(1) === recordedTrack.client_track_id) {
+ correspondingTracks.push(recordedTrack);
+ }
+ } else if (mixer.id.indexOf("C") === 0) {
+ if (mixer.id.substring(1) === recordedTrack.client_id) {
+ correspondingTracks.push(recordedTrack);
+ }
+ } else {
+ alert("Invalid state: the recorded track had neither persisted_track_id or persisted_client_id");
+ }
+ }
+
+ if (correspondingTracks.length === 0) {
+ noCorrespondingTracks = true;
+ // session?.app?.notify({
+ // title: "Unable to Open Recording",
+ // text: "Could not correlate server and client tracks",
+ // icon_url: "/assets/content/icon_alert_big.png"
+ // });
+ return recordedTracks;
+ }
+
+ const oneOfTheTracks = correspondingTracks[0];
+ const instrumentIcon = getInstrumentIcon24(oneOfTheTracks.instrument_id);
+ let userName = oneOfTheTracks.user.name;
+ if (!userName) {
+ userName = oneOfTheTracks.user.first_name + ' ' + oneOfTheTracks.user.last_name;
+ }
+
+ const data = {
+ recordingName: recordingName,
+ isOpener: isOpener,
+ userName: userName,
+ instrumentIcon: instrumentIcon,
+ track: oneOfTheTracks,
+ mixers: mediaMixers(mixer, isOpener, currentAllMixers)
+ };
+
+ recordedTracks.push(data);
+ }
+ }
+
+ return recordedTracks;
+ }, [session]);
+
+ const resolveMetronome = useCallback((currentMetronomeTrackMixers, currentAllMixers) => {
+ if (currentMetronomeTrackMixers.length === 0) return null;
+
+ const mixer = currentMetronomeTrackMixers[0];
+
+ const instrumentIcon = "/assets/content/icon_metronome.png";
+
+ const metronome = {
+ instrumentIcon: instrumentIcon,
+ mixers: mediaMixers(mixer, true, currentAllMixers)
+ };
+
+ return metronome;
+ }, []);
+
+ const resolveChatMixer = useCallback((currentMixersByResourceId, currentAllMixers) => {
+ const masterChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.MASTER, currentAllMixers);
+
+ if (masterChatMixers.length === 0) return null;
+
+ const personalChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.PERSONAL, currentAllMixers);
+
+ if (personalChatMixers.length === 0) {
+ logger.warn("unable to find personal mixer for voice chat");
+ return null;
+ }
+
+ const masterChatMixer = masterChatMixers[0];
+ const personalChatMixer = personalChatMixers[0];
+
+ return {
+ master: {
+ mixer: masterChatMixer,
+ muteMixer: masterChatMixer,
+ vuMixer: masterChatMixer,
+ oppositeMixer: personalChatMixer
+ },
+ personal: {
+ mixer: personalChatMixer,
+ muteMixer: personalChatMixer,
+ vuMixer: personalChatMixer,
+ oppositeMixer: masterChatMixer
+ }
+ };
+ }, []);
+
+ const groupTypes = useCallback(() => {
+ // console.debug("useMixerHelper: groupTypes called", {session, allMixers});
+
+ if(!session || Object.keys(allMixers).length === 0) return;
+
+
+ const localMediaMixers = mixersForGroupIds(mediaTrackGroups, MIX_MODES.MASTER, allMixers);
+ const peerLocalMediaMixers = mixersForGroupId(ChannelGroupIds.PeerMediaTrackGroup, MIX_MODES.MASTER, allMixers);
+
+ const newRecordingTrackMixers = [];
+ const newBackingTrackMixers = [];
+ const newJamTrackMixers = [];
+ const newMetronomeTrackMixers = [];
+ const newAdhocTrackMixers = [];
+
+ const groupByType = (mixers, isLocalMixer) => {
+ for (const mixer of mixers) {
+ const mediaType = mixer.media_type;
+ const groupId = mixer.group_id;
+
+ if (mediaType === 'MetronomeTrack' || groupId === ChannelGroupIds.MetronomeGroup) {
+ newMetronomeTrackMixers.push(mixer);
+ } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
+ let isJamTrack = false;
+
+ if (mixer.id === session.jamTrackMixdown()?.id) {
+ isJamTrack = true;
+ }
+
+ if (!isJamTrack && session.jamTracks()) {
+ for (const jamTrack of session.jamTracks()) {
+ if (mixer.id === jamTrack.id) {
+ isJamTrack = true;
+ break;
+ }
+ }
+ }
+
+ if (!isJamTrack && session.recordedJamTracks()) {
+ for (const recordedJamTrack of session.recordedJamTracks()) {
+ if (mixer.id === recordedJamTrack.id) {
+ isJamTrack = true;
+ break;
+ }
+ }
+ }
+
+ if (isJamTrack) {
+ newJamTrackMixers.push(mixer);
+ } else {
+ let isBackingTrack = false;
+ if (session.recordedBackingTracks()) {
+ for (const recordedBackingTrack of session.recordedBackingTracks()) {
+ if (mixer.id === 'L' + recordedBackingTrack.client_track_id) {
+ isBackingTrack = true;
+ break;
+ }
+ }
+ }
+
+ if (session.backingTracks()) {
+ for (const backingTrack of session.backingTracks()) {
+ if (mixer.id === 'L' + backingTrack.client_track_id) {
+ isBackingTrack = true;
+ break;
+ }
+ }
+ }
+
+ if (isBackingTrack) {
+ newBackingTrackMixers.push(mixer);
+ } else {
+ newRecordingTrackMixers.push(mixer);
+ }
+ }
+ } else if (mediaType === 'PeerMediaTrack' || mediaType === 'BackingTrack') {
+ newBackingTrackMixers.push(mixer);
+ } else if (mediaType === 'JamTrack') {
+ newJamTrackMixers.push(mixer);
+ } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
+ newRecordingTrackMixers.push(mixer);
+ } else {
+ if (mediaType !== 'Broadcast') {
+ logger.warn("Unknown track type: " + mediaType);
+ newAdhocTrackMixers.push(mixer);
+ }
+ }
+ }
+ };
+
+ groupByType(localMediaMixers, true);
+ groupByType(peerLocalMediaMixers, false);
+
+ setRecordingTrackMixers(newRecordingTrackMixers);
+ setBackingTrackMixers(newBackingTrackMixers);
+ setJamTrackMixers(newJamTrackMixers);
+ setMetronomeTrackMixers(newMetronomeTrackMixers);
+ setAdhocTrackMixers(newAdhocTrackMixers);
+
+ const newBackingTracks = resolveBackingTracks(newBackingTrackMixers, allMixers);
+ const newJamTracks = resolveJamTracks(newJamTrackMixers, allMixers);
+ const newRecordedTracks = resolveRecordedTracks(newRecordingTrackMixers, allMixers);
+ const newMetronome = resolveMetronome(newMetronomeTrackMixers, allMixers);
+
+ setBackingTracks(newBackingTracks);
+ setJamTracks(newJamTracks);
+ setRecordedTracks(newRecordedTracks);
+ setMetronome(newMetronome);
+
+ const newMediaSummary = {
+ recordingOpen: newRecordedTracks.length > 0,
+ jamTrackOpen: newJamTracks.length > 0,
+ backingTrackOpen: newBackingTracks.length > 0,
+ metronomeOpen: session.isMetronomeOpen()
+ };
+
+ let mediaOpenSummary = false;
+ for (const mediaType in newMediaSummary) {
+ if (newMediaSummary[mediaType]) {
+ mediaOpenSummary = true;
+ break;
+ }
+ }
+
+ newMediaSummary.mediaOpen = mediaOpenSummary;
+ newMediaSummary.userNeedsMediaControls = newMediaSummary.mediaOpen || window.JamTrackStore?.jamTrack;
+ newMediaSummary.jamTrack = window.JamTrackStore?.jamTrack;
+
+ let isOpener = false;
+ if (newMediaSummary.recordingOpen) {
+ isOpener = newRecordedTracks[0].isOpener;
+ } else if (newMediaSummary.jamTrackOpen) {
+ isOpener = newJamTracks[0].isOpener;
+ } else if (newMediaSummary.backingTrackOpen) {
+ isOpener = newBackingTracks[0].isOpener;
+ }
+
+ newMediaSummary.isOpener = isOpener;
+ setMediaSummary(newMediaSummary);
+
+ prepareSimulatedMixers(allMixers);
+ }, [session, allMixers]);
+
+ const mixersForGroupIds = useCallback((groupIds, mixMode, currentAllMixers) => {
+ const foundMixers = [];
+ const modePrefix = mixMode === MIX_MODES.MASTER ? 'M' : 'P';
+
+ for (const mixerKey in currentAllMixers) {
+ if (mixerKey.startsWith(modePrefix)) {
+ const mixer = currentAllMixers[mixerKey];
+ if (mixer && groupIds.includes(mixer.group_id)) {
+ foundMixers.push(mixer);
+ }
+ }
+ }
+
+ return foundMixers;
+ }, []);
+
+
+
+
+
+ const getMixerByResourceId = useCallback((resourceId, mode) => {
+ const mixerPair = mixersByResourceId[resourceId];
+
+ if (!mixerPair) return null;
+
+ if (!mode) {
+ return mixerPair;
+ } else {
+ if (mode === MIX_MODES.MASTER) {
+ return mixerPair.master;
+ } else {
+ return mixerPair.personal;
+ }
+ }
+ }, [mixersByResourceId]);
+
+
+
+ const mute = useCallback(async (mixerId, mode, muting) => {
+ if (mode == null) { mode = mixMode; }
+
+ const mixer = fillTrackVolumeObject(mixerId, mode);
+ if (!mixer) return;
+
+ context.trackVolumeObject.mute = muting;
+ await context.jamClient.SessionSetTrackVolumeData(mixerId, mode, context.trackVolumeObject);
+
+ const updatedMixer = getMixer(mixerId, mode);
+ updatedMixer.mute = muting;
+ }, [mixMode, getMixer, fillTrackVolumeObject]);
+
+ const getOriginalVolume = useCallback((mixers, gainType) => {
+ let originalVolume = null;
+ if (gainType === 'music') {
+ for (const mixer of mixers) {
+ if (mixer.name !== CategoryGroupIds.UserMedia && mixer.name !== CategoryGroupIds.MediaTrack) {
+ originalVolume = mixer.volume_left;
+ break;
+ }
+ }
+ } else {
+ originalVolume = mixers[0].volume_left;
+ }
+
+ return originalVolume;
+ }, []);
+
+ const faderChanged = useCallback(async (data, mixers, gainType, controlGroup) => {
+ //console.log("MixerHelper: faderChanged called", { data, mixers, gainType, controlGroup });
+ if (!Array.isArray(mixers)) {
+ mixers = [mixers];
+ }
+ const originalVolume = getOriginalVolume(mixers, gainType);
+
+ console.log("MixerHelper: faderChanged called", { data, mixers, gainType, controlGroup, originalVolume });
+
+ // Handle multiple mixers (master + personal pairs like web version)
+ const mixerIds = mixers.map(m => m.id);
+ const hasMasterAndPersonalControls = mixerIds.length === 2;
+
+ for (let i = 0; i < mixers.length; i++) {
+ const m = mixers[i];
+
+ // Broadcast only when NOT dragging (matches web version logic)
+ const broadcast = !(data.dragging);
+
+ // Determine mode for multiple mixers (like web version)
+ let mode = m.mode;
+ if (hasMasterAndPersonalControls) {
+ mode = i === 0 ? MIX_MODES.MASTER : MIX_MODES.PERSONAL;
+ }
+
+ const mixer = fillTrackVolumeObject(m.id, mode, allMixers, broadcast);
+ if (mixer == null) {
+ console.error("MixerHelper: faderChanged: mixer is null, skipping", m, gainType, controlGroup);
+ continue;
+ }
+
+ // Handle relative volume adjustments for music category (matches web version)
+ const relative = gainType === 'music' && (mixer.name === CategoryGroupIds.UserMedia || mixer.name === CategoryGroupIds.MediaTrack);
+
+ await setMixerVolume(mixer, data.percentage, relative, originalVolume, controlGroup, allMixers);
+
+ // Update local mixer state - create new state object to trigger React re-renders
+ setAllMixers(prev => ({
+ ...prev,
+ [`${mixer.mode ? 'M' : 'P'}${mixer.id}`]: {
+ ...prev[`${mixer.mode ? 'M' : 'P'}${mixer.id}`],
+ volume_left: trackVolumeObject.volL
+ }
+ }));
+ }
+ }, [getOriginalVolume, getMixer, fillTrackVolumeObject, setMixerVolume, allMixers]);
+
+ const initGain = useCallback((mixer) => {
+ if (Array.isArray(mixer)) {
+ mixer = mixer[0];
+ }
+
+ // In React version, fader initialization is handled through component state
+ // This function is kept for API compatibility with web version
+ // No DOM manipulation needed since React handles state updates
+ console.debug('initGain called for mixer:', mixer?.id);
+ }, []);
+
+ const panChanged = useCallback(async (data, mixers, groupId) => {
+ if (!Array.isArray(mixers)) { mixers = [mixers]; }
+ const result = [];
+ for (const mixer of mixers) {
+ const broadcast = !(data.dragging);
+ const filledMixer = fillTrackVolumeObject(mixer.id, mixer.mode, allMixers, broadcast);
+
+ await setMixerPan(filledMixer, data.percentage);
+
+ const updatedMixer = getMixer(filledMixer.id, filledMixer.mode, allMixers);
+ updatedMixer.pan = trackVolumeObject.pan;
+ result.push(trackVolumeObject.pan);
+ }
+ return result;
+ }, [getMixer, fillTrackVolumeObject, setMixerPan, allMixers]);
+
+ const initPan = useCallback((mixer) => {
+ const panPercent = panHelpers.convertPanToPercent(mixer.pan);
+ faderHelpers.setFaderValue(mixer.id, panPercent, Math.abs(mixer.pan));
+ faderHelpers.showFader(mixer.id);
+ }, [faderHelpers]);
+
+ const loopChanged = useCallback(async (mixer, shouldLoop) => {
+ fillTrackVolumeObject(mixer.id, mixer.mode, allMixers);
+ //context.trackVolumeObject.loop = shouldLoop;
+ setTrackVolumeObject(prev => ({
+ ...prev,
+ loop: shouldLoop
+ }));
+ await context.jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, context.trackVolumeObject);
+
+ const updatedMixer = getMixer(mixer.id, mixer.mode, allMixers);
+ updatedMixer.loop = context.trackVolumeObject.loop;
+ }, [getMixer, fillTrackVolumeObject, allMixers]);
+
+ const percentFromMixerValue = useCallback((min, max, value) => {
+ try {
+ const range = Math.abs(max - min);
+ const magnitude = value - min;
+ const percent = Math.round(100 * (magnitude / range));
+ return percent;
+ } catch (err) {
+ return 0;
+ }
+ }, []);
+
+ const percentToMixerValue = useCallback((min, max, percent) => {
+ const range = Math.abs(max - min);
+ const multiplier = percent / 100;
+ let value = min + (multiplier * range);
+
+ if (value < min) {
+ value = min;
+ }
+
+ if (value > max) {
+ value = max;
+ }
+
+ return value;
+ }, []);
+
+ const collectStats = useCallback((mixer, currentVuStats) => {
+ let mixerStats = currentVuStats[mixer.id];
+
+ if (!mixerStats) {
+ mixerStats = { count: 0, group_name: context.JK.groupIdDisplay(mixer) };
+ currentVuStats[mixer.id] = mixerStats;
+ }
+
+ mixerStats.count++;
+ }, []);
+
+ const dumpVUStats = useCallback((currentVuStats) => {
+ logger.debug("VU STAT DUMP");
+ for (const mixerId in currentVuStats) {
+ const mixerStat = currentVuStats[mixerId];
+ logger.debug("VU STAT: #{mixerStat.group_name} count=#{mixerStat.count}");
+ }
+ }, []);
+
+ // Change updateVU to use the ref
+ const updateVU = useCallback((mixerId, mode, leftValue, leftClipping, rightValue, rightClipping) => {
+ if (!isReady.current) return;
+
+ const mixer = getMixer(mixerId, mode);
+
+ if (mixer) {
+ // console.log("useMixerHelper: updateVU mixer", allMixersRef.current, mixerId, mode, mixer);
+ updateVU3(mixer, leftValue, leftClipping, rightValue, rightClipping);
+ }
+ }, []);
+
+ const getTrackInfo = useCallback(async () => {
+ return await context.JK.TrackHelpers.getTrackInfo(context.jamClient, masterMixers);
+ }, [masterMixers]);
+
+ const prepareSimulatedMixers = useCallback(() => {
+ const newSimulatedMusicCategoryMixers = {};
+ const newSimulatedChatCategoryMixers = {};
+
+ newSimulatedMusicCategoryMixers[MIX_MODES.MASTER] = getSimulatedMusicCategoryMixer(MIX_MODES.MASTER, allMixers);
+ newSimulatedMusicCategoryMixers[MIX_MODES.PERSONAL] = getSimulatedMusicCategoryMixer(MIX_MODES.PERSONAL, allMixers);
+
+ // Call getSimulatedChatCategoryMixer once and assign parts like web project
+ const chatMixerData = getSimulatedChatCategoryMixer(allMixers);
+ newSimulatedChatCategoryMixers[MIX_MODES.MASTER] = chatMixerData?.master;
+ newSimulatedChatCategoryMixers[MIX_MODES.PERSONAL] = chatMixerData?.personal;
+
+ setSimulatedMusicCategoryMixers(newSimulatedMusicCategoryMixers);
+ setSimulatedChatCategoryMixers(newSimulatedChatCategoryMixers);
+ }, [allMixers]);
+
+ const getSimulatedMusicCategoryMixer = useCallback((mode, currentAllMixers) => {
+ const myInputs = getAudioInputCategoryMixer(mode, currentAllMixers)?.mixer;
+ const peerInputs = getUserMusicCategoryMixer(mode, currentAllMixers)?.mixer;
+ const myMedia = getMediaCategoryMixer(mode, currentAllMixers)?.mixer;
+ const peerMedia = getUserMediaCategoryMixer(mode, currentAllMixers)?.mixer;
+ const metronome = getMetronomeCategoryMixer(mode, currentAllMixers)?.mixer;
+ const output = getOutputMixer(mode, currentAllMixers);
+ const oppositeOutput = getOutputMixer(!mode, currentAllMixers);
+
+ if (myInputs) {
+ return {
+ first: myInputs,
+ mixer: [myInputs, peerInputs, myMedia, peerMedia, metronome].filter(m => m),
+ muteMixer: [myInputs, peerInputs, myMedia, peerMedia, metronome].filter(m => m),
+ vuMixer: output
+ };
+ } else {
+ return null;
+ }
+ }, [getAudioInputCategoryMixer, getUserMusicCategoryMixer, getMediaCategoryMixer, getUserMediaCategoryMixer, getMetronomeCategoryMixer, getOutputMixer]);
+
+ const getSimulatedChatCategoryMixer = useCallback((currentAllMixers) => {
+ // Find chat mixers by group_id like the web project does
+
+ const masterChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.MASTER, currentAllMixers);
+ const personalChatMixers = mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.PERSONAL, currentAllMixers);
+
+
+
+ if (masterChatMixers.length === 0 || personalChatMixers.length === 0) {
+ return null;
+ }
+
+ const masterChatMixer = masterChatMixers[0];
+ const personalChatMixer = personalChatMixers[0];
+
+ console.debug("useMixerHelper: getSimulatedChatCategoryMixer", masterChatMixer, personalChatMixer);
+
+ // Return the same structure as web project's resolveChatMixer
+ return {
+ master: {
+ mixer: masterChatMixer,
+ muteMixer: masterChatMixer,
+ vuMixer: masterChatMixer,
+ oppositeMixer: personalChatMixer
+ },
+ personal: {
+ mixer: personalChatMixer,
+ muteMixer: personalChatMixer,
+ vuMixer: personalChatMixer,
+ oppositeMixer: masterChatMixer
+ }
+ };
+ }, []);
+
+ const refreshMixer = useCallback((mixers, currentAllMixers) => {
+ if (!mixers || !mixers.mixer) return null;
+
+ let updateMixers = null;
+ if (Array.isArray(mixers.mixer)) {
+ if (mixers.mixer.length > 0) {
+ updateMixers = [];
+ for (const mixer of mixers.mixer) {
+ updateMixers.push(getMixer(mixer.id, mixer.mode, currentAllMixers));
+ }
+ }
+ } else {
+ updateMixers = getMixer(mixers.mixer.id, mixers.mixer.mode, currentAllMixers);
+ }
+
+ let updatedVUMixers = null;
+ if (Array.isArray(mixers.vuMixer)) {
+ updatedVUMixers = [];
+ for (const vuMixer of mixers.vuMixer) {
+ updatedVUMixers.push(getMixer(vuMixer.id, vuMixer.mode, currentAllMixers));
+ }
+ } else {
+ updatedVUMixers = getMixer(mixers.vuMixer.id, mixers.vuMixer.mode, currentAllMixers);
+ }
+
+ let updateMuteMixers = null;
+ if (Array.isArray(mixers.muteMixer)) {
+ updateMuteMixers = [];
+ for (const muteMixer of mixers.muteMixer) {
+ updateMuteMixers.push(getMixer(muteMixer.id, muteMixer.mode, currentAllMixers));
+ }
+ } else {
+ updateMuteMixers = getMixer(mixers.muteMixer.id, mixers.muteMixer.mode, currentAllMixers);
+ }
+
+ const oppositeMixer = mixers.oppositeMixer ? getMixer(mixers.oppositeMixer.id, mixers.oppositeMixer.mode, currentAllMixers) : null;
+
+ if (updateMixers) {
+ return {
+ mixer: updateMixers,
+ vuMixer: updatedVUMixers,
+ muteMixer: updateMuteMixers,
+ oppositeMixer: oppositeMixer
+ };
+ } else {
+ return null;
+ }
+ }, [getMixer]);
+
+ const recordingName = useCallback(() => {
+ return session.recordingName();
+ }, [session]);
+
+ const jamTrackName = useCallback(() => {
+ return session.jamTrackName();
+ }, [session]);
+
+
+ return {
+ isReady,
+ session,
+ // masterMixers,
+ // personalMixers,
+ mixers: allMixers,
+ myTracks,
+ findMixerForTrack,
+ updateMixerData,
+ updateVU,
+ simulatedMusicCategoryMixers,
+ simulatedChatCategoryMixers,
+ mixMode,
+ faderChanged,
+ initGain,
+ setMixerPan
+ };
+}
+
+export default useMixerHelper;
diff --git a/jam-ui/src/hooks/useMixerStore.js b/jam-ui/src/hooks/useMixerStore.js
index aff758824..c47cb7288 100644
--- a/jam-ui/src/hooks/useMixerStore.js
+++ b/jam-ui/src/hooks/useMixerStore.js
@@ -1,6 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { selectInSession } from '../store/features/activeSessionSlice';
+import {
+ selectMetronomeSettings,
+ selectNoAudioUsers,
+ selectClientsWithAudioOverride
+} from '../store/features/mixersSlice';
import { throttle } from 'lodash'; // Add lodash for throttling
//import { useJamClient } from '../context/JamClientContext';
import { MIX_MODES } from '../helpers/globals';
@@ -19,6 +24,12 @@ export default function useMixerStore() {
//const jamClient = useJamClient();
// Phase 4: Replace CurrentSessionContext with Redux
const inSession = useSelector(selectInSession);
+
+ // Phase 5: Replace duplicate state with Redux selectors
+ const metro = useSelector(selectMetronomeSettings);
+ const noAudioUsers = useSelector(selectNoAudioUsers);
+ const clientsWithAudioOverride = useSelector(selectClientsWithAudioOverride);
+
const {
jamClient,
} = useJamServerContext();
@@ -27,13 +38,9 @@ export default function useMixerStore() {
const logger = console; // Replace with your logging mechanism if needed
//const { updateVU } = useVuHelpers();
-
- // State management
- const [metro, setMetro] = useState({ tempo: 120, cricket: false, sound: "Beep" });
- const [noAudioUsers, setNoAudioUsers] = useState({});
+ // Local state (not duplicated in Redux)
const [checkingMissingPeers, setCheckingMissingPeers] = useState({});
const [missingMixerPeers, setMissingMixerPeers] = useState({});
- const [clientsWithAudioOverride, setClientsWithAudioOverride] = useState({});
const [vuMeterUpdatePrefMap, setVuMeterUpdatePrefMap] = useState({ count: 0 });
const [session, setSession] = useState(null);
//const [masterMixers, setMasterMixers] = useState(null);
@@ -178,8 +185,8 @@ export default function useMixerStore() {
// Session management
const sessionEnded = useCallback(() => {
+ // Phase 5: Local state only - Redux handles noAudioUsers cleanup
setCheckingMissingPeers({});
- setNoAudioUsers({});
setMissingMixerPeers({});
if (recheckTimeoutRef.current) {
clearTimeout(recheckTimeoutRef.current);
diff --git a/jam-ui/src/hooks/useSessionWebSocket.js b/jam-ui/src/hooks/useSessionWebSocket.js
index 39062342e..5a1516189 100644
--- a/jam-ui/src/hooks/useSessionWebSocket.js
+++ b/jam-ui/src/hooks/useSessionWebSocket.js
@@ -17,6 +17,16 @@ import {
addRecordedTrack,
setConnectionStatus
} from '../store/features/activeSessionSlice';
+import {
+ updateMediaSummary,
+ setMetronome
+} from '../store/features/mixersSlice';
+import {
+ setBackingTracks,
+ setJamTracks,
+ setRecordedTracks,
+ updateJamTrackState
+} from '../store/features/mediaSlice';
/**
* Custom hook to integrate WebSocket messages with Redux state
@@ -106,6 +116,29 @@ export const useSessionWebSocket = (sessionId) => {
dispatch(addRecordedTrack(data.track));
},
+ // Phase 5: Mixer and Media events
+ MIXER_CHANGES: (sessionMixers) => {
+ console.log('Mixer changes received:', sessionMixers);
+ const session = sessionMixers.session;
+ const mixers = sessionMixers.mixers;
+
+ // Update media summary
+ if (mixers.mediaSummary) {
+ dispatch(updateMediaSummary(mixers.mediaSummary));
+ }
+
+ // Update media arrays
+ dispatch(setBackingTracks(mixers.backingTracks || []));
+ dispatch(setJamTracks(mixers.jamTracks || []));
+ dispatch(setRecordedTracks(mixers.recordedTracks || []));
+ dispatch(setMetronome(mixers.metronome || null));
+ },
+
+ JAM_TRACK_CHANGES: (changes) => {
+ console.log('Jam track changes received:', changes);
+ dispatch(updateJamTrackState(changes));
+ },
+
// Connection events
connectionStatusChanged: (data) => {
console.log('Connection status changed:', data);
diff --git a/jam-ui/src/layouts/JKClientLayout.js b/jam-ui/src/layouts/JKClientLayout.js
index d13a62c89..b5cb193a8 100644
--- a/jam-ui/src/layouts/JKClientLayout.js
+++ b/jam-ui/src/layouts/JKClientLayout.js
@@ -11,7 +11,6 @@ import { JamServerProvider } from '../context/JamServerContext';
import { MixersProvider } from '../context/MixersContext';
import { VuProvider } from '../context/VuContext';
import { GlobalProvider } from '../context/GlobalContext';
-import { MediaProvider } from '../context/MediaContext';
const JKClientLayout = ({ location }) => {
@@ -27,9 +26,7 @@ const JKClientLayout = ({ location }) => {
-
-
-
+
diff --git a/jam-ui/src/store/features/__tests__/mediaSlice.test.js b/jam-ui/src/store/features/__tests__/mediaSlice.test.js
new file mode 100644
index 000000000..bdce859c4
--- /dev/null
+++ b/jam-ui/src/store/features/__tests__/mediaSlice.test.js
@@ -0,0 +1,433 @@
+import mediaReducer, {
+ openBackingTrack,
+ loadJamTrack,
+ closeMedia,
+ setBackingTracks,
+ setJamTracks,
+ setRecordedTracks,
+ updateJamTrackState,
+ clearJamTrackState,
+ clearAllMedia,
+ selectBackingTracks,
+ selectJamTracks,
+ selectRecordedTracks,
+ selectJamTrackState,
+ selectDownloadingJamTrack,
+ selectMediaLoading,
+ selectMediaError
+} from '../mediaSlice';
+
+describe('mediaSlice', () => {
+ const initialState = {
+ backingTracks: [],
+ jamTracks: [],
+ recordedTracks: [],
+ jamTrackState: {},
+ downloadingJamTrack: false,
+ loading: {
+ backingTrack: false,
+ jamTrack: false,
+ closing: false
+ },
+ error: null
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('reducer', () => {
+ it('should return the initial state', () => {
+ expect(mediaReducer(undefined, { type: 'unknown' })).toEqual(initialState);
+ });
+ });
+
+ describe('synchronous actions', () => {
+ it('should handle setBackingTracks', () => {
+ const tracks = [
+ { id: 1, name: 'Track 1', isOpener: true },
+ { id: 2, name: 'Track 2', isOpener: false }
+ ];
+
+ const actual = mediaReducer(initialState, setBackingTracks(tracks));
+ expect(actual.backingTracks).toEqual(tracks);
+ expect(actual.backingTracks).toHaveLength(2);
+ });
+
+ it('should handle setJamTracks', () => {
+ const tracks = [
+ { id: 1, name: 'Jam Track 1', part: 'drums' },
+ { id: 2, name: 'Jam Track 2', part: 'bass' }
+ ];
+
+ const actual = mediaReducer(initialState, setJamTracks(tracks));
+ expect(actual.jamTracks).toEqual(tracks);
+ });
+
+ it('should handle setRecordedTracks', () => {
+ const tracks = [
+ { id: 1, recordingName: 'Recording 1' }
+ ];
+
+ const actual = mediaReducer(initialState, setRecordedTracks(tracks));
+ expect(actual.recordedTracks).toEqual(tracks);
+ });
+
+ it('should handle updateJamTrackState', () => {
+ const actual = mediaReducer(
+ initialState,
+ updateJamTrackState({ playing: true, position: 1.5 })
+ );
+
+ expect(actual.jamTrackState.playing).toBe(true);
+ expect(actual.jamTrackState.position).toBe(1.5);
+ });
+
+ it('should merge jam track state updates', () => {
+ const stateWithJamTrack = {
+ ...initialState,
+ jamTrackState: { playing: true, position: 1.5 }
+ };
+
+ const actual = mediaReducer(
+ stateWithJamTrack,
+ updateJamTrackState({ position: 2.0, volume: 0.8 })
+ );
+
+ expect(actual.jamTrackState.playing).toBe(true); // Preserved
+ expect(actual.jamTrackState.position).toBe(2.0); // Updated
+ expect(actual.jamTrackState.volume).toBe(0.8); // Added
+ });
+
+ it('should handle clearJamTrackState', () => {
+ const stateWithJamTrack = {
+ ...initialState,
+ jamTrackState: { playing: true, position: 1.5 }
+ };
+
+ const actual = mediaReducer(stateWithJamTrack, clearJamTrackState());
+ expect(actual.jamTrackState).toEqual({});
+ });
+
+ it('should handle clearAllMedia', () => {
+ const stateWithMedia = {
+ ...initialState,
+ backingTracks: [{ id: 1 }],
+ jamTracks: [{ id: 2 }],
+ recordedTracks: [{ id: 3 }],
+ jamTrackState: { playing: true },
+ downloadingJamTrack: true,
+ error: 'Some error'
+ };
+
+ const actual = mediaReducer(stateWithMedia, clearAllMedia());
+ expect(actual.backingTracks).toEqual([]);
+ expect(actual.jamTracks).toEqual([]);
+ expect(actual.recordedTracks).toEqual([]);
+ expect(actual.jamTrackState).toEqual({});
+ expect(actual.downloadingJamTrack).toBe(false);
+ expect(actual.error).toBeNull();
+ });
+ });
+
+ describe('async thunks: openBackingTrack', () => {
+ it('should handle openBackingTrack.pending', () => {
+ const action = { type: openBackingTrack.pending.type };
+ const actual = mediaReducer(initialState, action);
+
+ expect(actual.loading.backingTrack).toBe(true);
+ expect(actual.error).toBeNull();
+ });
+
+ it('should handle openBackingTrack.fulfilled', () => {
+ const stateWithLoading = {
+ ...initialState,
+ loading: { ...initialState.loading, backingTrack: true }
+ };
+
+ const action = {
+ type: openBackingTrack.fulfilled.type,
+ payload: { file: 'track.mp3' }
+ };
+
+ const actual = mediaReducer(stateWithLoading, action);
+ expect(actual.loading.backingTrack).toBe(false);
+ });
+
+ it('should handle openBackingTrack.rejected', () => {
+ const stateWithLoading = {
+ ...initialState,
+ loading: { ...initialState.loading, backingTrack: true }
+ };
+
+ const action = {
+ type: openBackingTrack.rejected.type,
+ payload: 'Failed to open backing track'
+ };
+
+ const actual = mediaReducer(stateWithLoading, action);
+ expect(actual.loading.backingTrack).toBe(false);
+ expect(actual.error).toBe('Failed to open backing track');
+ });
+
+ it('should call jamClient.SessionOpenBackingTrackFile', async () => {
+ const mockJamClient = {
+ SessionOpenBackingTrackFile: jest.fn().mockResolvedValue()
+ };
+
+ const dispatch = jest.fn();
+ const thunk = openBackingTrack({
+ file: 'test.mp3',
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.SessionOpenBackingTrackFile).toHaveBeenCalledWith('test.mp3', false);
+ });
+ });
+
+ describe('async thunks: loadJamTrack', () => {
+ it('should handle loadJamTrack.pending', () => {
+ const action = { type: loadJamTrack.pending.type };
+ const actual = mediaReducer(initialState, action);
+
+ expect(actual.loading.jamTrack).toBe(true);
+ expect(actual.downloadingJamTrack).toBe(true);
+ expect(actual.error).toBeNull();
+ });
+
+ it('should handle loadJamTrack.fulfilled', () => {
+ const stateWithLoading = {
+ ...initialState,
+ loading: { ...initialState.loading, jamTrack: true },
+ downloadingJamTrack: true
+ };
+
+ const action = {
+ type: loadJamTrack.fulfilled.type,
+ payload: { jamTrack: { id: 123 }, result: true }
+ };
+
+ const actual = mediaReducer(stateWithLoading, action);
+ expect(actual.loading.jamTrack).toBe(false);
+ expect(actual.downloadingJamTrack).toBe(false);
+ });
+
+ it('should handle loadJamTrack.rejected', () => {
+ const stateWithLoading = {
+ ...initialState,
+ loading: { ...initialState.loading, jamTrack: true },
+ downloadingJamTrack: true
+ };
+
+ const action = {
+ type: loadJamTrack.rejected.type,
+ payload: 'Unable to open JamTrack'
+ };
+
+ const actual = mediaReducer(stateWithLoading, action);
+ expect(actual.loading.jamTrack).toBe(false);
+ expect(actual.downloadingJamTrack).toBe(false);
+ expect(actual.error).toBe('Unable to open JamTrack');
+ });
+
+ it('should call jamClient.JamTrackPlay without JMep', async () => {
+ const mockJamClient = {
+ JamTrackPlay: jest.fn().mockResolvedValue(true)
+ };
+
+ const dispatch = jest.fn();
+ const thunk = loadJamTrack({
+ jamTrack: { id: 123, name: 'Test Track' },
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.JamTrackPlay).toHaveBeenCalledWith(123);
+ });
+
+ it('should call jamClient.JamTrackLoadJmep when JMep data available', async () => {
+ const mockJamClient = {
+ GetSampleRate: jest.fn().mockResolvedValue(48),
+ JamTrackLoadJmep: jest.fn().mockResolvedValue(),
+ JamTrackPlay: jest.fn().mockResolvedValue(true)
+ };
+
+ const dispatch = jest.fn();
+ const thunk = loadJamTrack({
+ jamTrack: { id: 123, name: 'Test Track', jmep: 'base64data...' },
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.GetSampleRate).toHaveBeenCalled();
+ expect(mockJamClient.JamTrackLoadJmep).toHaveBeenCalledWith('123-48', 'base64data...');
+ expect(mockJamClient.JamTrackPlay).toHaveBeenCalledWith(123);
+ });
+
+ it('should use 44 sample rate filename when sample rate is not 48', async () => {
+ const mockJamClient = {
+ GetSampleRate: jest.fn().mockResolvedValue(44.1),
+ JamTrackLoadJmep: jest.fn().mockResolvedValue(),
+ JamTrackPlay: jest.fn().mockResolvedValue(true)
+ };
+
+ const dispatch = jest.fn();
+ const thunk = loadJamTrack({
+ jamTrack: { id: 456, jmep: 'base64data...' },
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.JamTrackLoadJmep).toHaveBeenCalledWith('456-44', 'base64data...');
+ });
+
+ it('should reject when JamTrackPlay returns false', async () => {
+ const mockJamClient = {
+ JamTrackPlay: jest.fn().mockResolvedValue(false)
+ };
+
+ const dispatch = jest.fn();
+ const getState = jest.fn();
+ const thunk = loadJamTrack({
+ jamTrack: { id: 123 },
+ jamClient: mockJamClient
+ });
+
+ const result = await thunk(dispatch, getState, undefined);
+
+ expect(result.type).toBe('media/loadJamTrack/rejected');
+ expect(result.payload).toBe('Unable to open JamTrack');
+ });
+ });
+
+ describe('async thunks: closeMedia', () => {
+ it('should handle closeMedia.pending', () => {
+ const action = { type: closeMedia.pending.type };
+ const actual = mediaReducer(initialState, action);
+
+ expect(actual.loading.closing).toBe(true);
+ });
+
+ it('should handle closeMedia.fulfilled and clear all media', () => {
+ const stateWithMedia = {
+ ...initialState,
+ backingTracks: [{ id: 1 }],
+ jamTracks: [{ id: 2 }],
+ recordedTracks: [{ id: 3 }],
+ jamTrackState: { playing: true },
+ loading: { ...initialState.loading, closing: true }
+ };
+
+ const action = { type: closeMedia.fulfilled.type };
+ const actual = mediaReducer(stateWithMedia, action);
+
+ expect(actual.loading.closing).toBe(false);
+ expect(actual.backingTracks).toEqual([]);
+ expect(actual.jamTracks).toEqual([]);
+ expect(actual.recordedTracks).toEqual([]);
+ expect(actual.jamTrackState).toEqual({});
+ });
+
+ it('should handle closeMedia.rejected', () => {
+ const stateWithLoading = {
+ ...initialState,
+ loading: { ...initialState.loading, closing: true }
+ };
+
+ const action = {
+ type: closeMedia.rejected.type,
+ payload: 'Failed to close media'
+ };
+
+ const actual = mediaReducer(stateWithLoading, action);
+ expect(actual.loading.closing).toBe(false);
+ expect(actual.error).toBe('Failed to close media');
+ });
+
+ it('should call jamClient.SessionCloseMedia with force parameter', async () => {
+ const mockJamClient = {
+ SessionCloseMedia: jest.fn().mockResolvedValue()
+ };
+
+ const dispatch = jest.fn();
+ const thunk = closeMedia({
+ force: true,
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.SessionCloseMedia).toHaveBeenCalledWith(true);
+ });
+
+ it('should default force to false', async () => {
+ const mockJamClient = {
+ SessionCloseMedia: jest.fn().mockResolvedValue()
+ };
+
+ const dispatch = jest.fn();
+ const thunk = closeMedia({
+ jamClient: mockJamClient
+ });
+
+ await thunk(dispatch, () => ({}), undefined);
+
+ expect(mockJamClient.SessionCloseMedia).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('selectors', () => {
+ const mockState = {
+ media: {
+ backingTracks: [{ id: 1, name: 'Backing 1' }],
+ jamTracks: [{ id: 2, name: 'Jam 1' }],
+ recordedTracks: [{ id: 3, name: 'Recording 1' }],
+ jamTrackState: { playing: true, position: 2.5 },
+ downloadingJamTrack: true,
+ loading: {
+ backingTrack: false,
+ jamTrack: true,
+ closing: false
+ },
+ error: 'Test error'
+ }
+ };
+
+ it('should select backing tracks', () => {
+ expect(selectBackingTracks(mockState)).toEqual([{ id: 1, name: 'Backing 1' }]);
+ });
+
+ it('should select jam tracks', () => {
+ expect(selectJamTracks(mockState)).toEqual([{ id: 2, name: 'Jam 1' }]);
+ });
+
+ it('should select recorded tracks', () => {
+ expect(selectRecordedTracks(mockState)).toEqual([{ id: 3, name: 'Recording 1' }]);
+ });
+
+ it('should select jam track state', () => {
+ expect(selectJamTrackState(mockState)).toEqual({ playing: true, position: 2.5 });
+ });
+
+ it('should select downloading jam track flag', () => {
+ expect(selectDownloadingJamTrack(mockState)).toBe(true);
+ });
+
+ it('should select media loading states', () => {
+ expect(selectMediaLoading(mockState)).toEqual({
+ backingTrack: false,
+ jamTrack: true,
+ closing: false
+ });
+ });
+
+ it('should select media error', () => {
+ expect(selectMediaError(mockState)).toBe('Test error');
+ });
+ });
+});
diff --git a/jam-ui/src/store/features/__tests__/mixersSlice.test.js b/jam-ui/src/store/features/__tests__/mixersSlice.test.js
new file mode 100644
index 000000000..3771651be
--- /dev/null
+++ b/jam-ui/src/store/features/__tests__/mixersSlice.test.js
@@ -0,0 +1,533 @@
+import mixersReducer, {
+ setMasterMixers,
+ setPersonalMixers,
+ organizeMixers,
+ setRecordingTrackMixers,
+ setBackingTrackMixers,
+ setJamTrackMixers,
+ setMetronomeTrackMixers,
+ setAdhocTrackMixers,
+ updateMixer,
+ setMetronome,
+ setMetronomeSettings,
+ updateMediaSummary,
+ setMediaSummary,
+ setChatMixer,
+ setBroadcastMixer,
+ setRecordingMixer,
+ setSimulatedMusicCategoryMixers,
+ setSimulatedChatCategoryMixers,
+ addNoAudioUser,
+ removeNoAudioUser,
+ setNoAudioUsers,
+ setClientsWithAudioOverride,
+ setMissingMixerPeers,
+ setCheckingMissingPeers,
+ clearMixers,
+ selectAllMixers,
+ selectMasterMixers,
+ selectPersonalMixers,
+ selectMixersByResourceId,
+ selectMixersByTrackId,
+ selectChatMixer,
+ selectBroadcastMixer,
+ selectRecordingMixer,
+ selectMetronome,
+ selectMetronomeSettings,
+ selectMediaSummary,
+ selectMixersReady,
+ selectBackingTrackMixers,
+ selectJamTrackMixers,
+ selectRecordingTrackMixers,
+ selectMetronomeTrackMixers,
+ selectAdhocTrackMixers,
+ selectSimulatedMusicCategoryMixers,
+ selectSimulatedChatCategoryMixers,
+ selectNoAudioUsers,
+ selectClientsWithAudioOverride,
+ selectMissingMixerPeers,
+ selectCheckingMissingPeers,
+ selectMixer,
+ selectMixerPairByResourceId,
+ selectMixerPairByTrackId
+} from '../mixersSlice';
+
+describe('mixersSlice', () => {
+ const initialState = {
+ chatMixer: null,
+ broadcastMixer: null,
+ recordingMixer: null,
+ recordingTrackMixers: [],
+ backingTrackMixers: [],
+ jamTrackMixers: [],
+ metronomeTrackMixers: [],
+ adhocTrackMixers: [],
+ masterMixers: [],
+ personalMixers: [],
+ allMixers: {},
+ mixersByResourceId: {},
+ mixersByTrackId: {},
+ simulatedMusicCategoryMixers: {
+ PERSONAL: null,
+ MASTER: null
+ },
+ simulatedChatCategoryMixers: {
+ PERSONAL: null,
+ MASTER: null
+ },
+ metronome: null,
+ metronomeSettings: {
+ tempo: 120,
+ sound: "Beep",
+ cricket: false
+ },
+ mediaSummary: {
+ mediaOpen: false,
+ backingTrackOpen: false,
+ jamTrackOpen: false,
+ recordingOpen: false,
+ metronomeOpen: false,
+ isOpener: false,
+ userNeedsMediaControls: false,
+ jamTrack: null
+ },
+ noAudioUsers: {},
+ clientsWithAudioOverride: {},
+ missingMixerPeers: {},
+ checkingMissingPeers: {},
+ isReady: false,
+ loading: false,
+ error: null
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('reducer', () => {
+ it('should return the initial state', () => {
+ expect(mixersReducer(undefined, { type: 'unknown' })).toEqual(initialState);
+ });
+ });
+
+ describe('master and personal mixers', () => {
+ it('should handle setMasterMixers', () => {
+ const masterMixers = [
+ { id: 1, rid: 'rid1', name: 'Master Mixer 1' },
+ { id: 2, rid: 'rid2', name: 'Master Mixer 2' }
+ ];
+
+ const actual = mixersReducer(initialState, setMasterMixers(masterMixers));
+ expect(actual.masterMixers).toEqual(masterMixers);
+ expect(actual.masterMixers).toHaveLength(2);
+ });
+
+ it('should handle setPersonalMixers', () => {
+ const personalMixers = [
+ { id: 1, rid: 'rid1', name: 'Personal Mixer 1' },
+ { id: 2, rid: 'rid2', name: 'Personal Mixer 2' }
+ ];
+
+ const actual = mixersReducer(initialState, setPersonalMixers(personalMixers));
+ expect(actual.personalMixers).toEqual(personalMixers);
+ expect(actual.personalMixers).toHaveLength(2);
+ });
+ });
+
+ describe('organizeMixers', () => {
+ it('should organize mixers and build lookup tables', () => {
+ const stateWithMixers = {
+ ...initialState,
+ masterMixers: [
+ { id: 1, rid: 'rid1', name: 'Master 1' },
+ { id: 2, rid: 'rid2', name: 'Master 2' }
+ ],
+ personalMixers: [
+ { id: 1, rid: 'rid1', name: 'Personal 1' },
+ { id: 2, rid: 'rid2', name: 'Personal 2' }
+ ]
+ };
+
+ const actual = mixersReducer(stateWithMixers, organizeMixers());
+
+ // Check allMixers
+ expect(actual.allMixers['M1']).toEqual({ id: 1, rid: 'rid1', name: 'Master 1' });
+ expect(actual.allMixers['M2']).toEqual({ id: 2, rid: 'rid2', name: 'Master 2' });
+ expect(actual.allMixers['P1']).toEqual({ id: 1, rid: 'rid1', name: 'Personal 1' });
+ expect(actual.allMixers['P2']).toEqual({ id: 2, rid: 'rid2', name: 'Personal 2' });
+
+ // Check mixersByResourceId
+ expect(actual.mixersByResourceId['rid1'].master).toEqual({ id: 1, rid: 'rid1', name: 'Master 1' });
+ expect(actual.mixersByResourceId['rid1'].personal).toEqual({ id: 1, rid: 'rid1', name: 'Personal 1' });
+ expect(actual.mixersByResourceId['rid2'].master).toEqual({ id: 2, rid: 'rid2', name: 'Master 2' });
+ expect(actual.mixersByResourceId['rid2'].personal).toEqual({ id: 2, rid: 'rid2', name: 'Personal 2' });
+
+ // Check mixersByTrackId
+ expect(actual.mixersByTrackId[1].master).toEqual({ id: 1, rid: 'rid1', name: 'Master 1' });
+ expect(actual.mixersByTrackId[1].personal).toEqual({ id: 1, rid: 'rid1', name: 'Personal 1' });
+
+ // Check isReady flag
+ expect(actual.isReady).toBe(true);
+ });
+
+ it('should handle personal mixer without matching master', () => {
+ const stateWithMixers = {
+ ...initialState,
+ masterMixers: [
+ { id: 1, rid: 'rid1', name: 'Master 1' }
+ ],
+ personalMixers: [
+ { id: 1, rid: 'rid1', name: 'Personal 1' },
+ { id: 2, rid: 'rid2', name: 'Personal 2 (no master)' }
+ ]
+ };
+
+ const actual = mixersReducer(stateWithMixers, organizeMixers());
+
+ // Personal mixer without master should still be added
+ expect(actual.allMixers['P2']).toEqual({ id: 2, rid: 'rid2', name: 'Personal 2 (no master)' });
+ expect(actual.mixersByResourceId['rid2'].personal).toEqual({ id: 2, rid: 'rid2', name: 'Personal 2 (no master)' });
+ expect(actual.mixersByResourceId['rid2'].master).toBeUndefined();
+ });
+ });
+
+ describe('mixer type arrays', () => {
+ it('should handle setRecordingTrackMixers', () => {
+ const mixers = [{ id: 1, name: 'Recording Track 1' }];
+ const actual = mixersReducer(initialState, setRecordingTrackMixers(mixers));
+ expect(actual.recordingTrackMixers).toEqual(mixers);
+ });
+
+ it('should handle setBackingTrackMixers', () => {
+ const mixers = [{ id: 2, name: 'Backing Track 1' }];
+ const actual = mixersReducer(initialState, setBackingTrackMixers(mixers));
+ expect(actual.backingTrackMixers).toEqual(mixers);
+ });
+
+ it('should handle setJamTrackMixers', () => {
+ const mixers = [{ id: 3, name: 'Jam Track 1' }];
+ const actual = mixersReducer(initialState, setJamTrackMixers(mixers));
+ expect(actual.jamTrackMixers).toEqual(mixers);
+ });
+
+ it('should handle setMetronomeTrackMixers', () => {
+ const mixers = [{ id: 4, name: 'Metronome Track 1' }];
+ const actual = mixersReducer(initialState, setMetronomeTrackMixers(mixers));
+ expect(actual.metronomeTrackMixers).toEqual(mixers);
+ });
+
+ it('should handle setAdhocTrackMixers', () => {
+ const mixers = [{ id: 5, name: 'Adhoc Track 1' }];
+ const actual = mixersReducer(initialState, setAdhocTrackMixers(mixers));
+ expect(actual.adhocTrackMixers).toEqual(mixers);
+ });
+ });
+
+ describe('updateMixer', () => {
+ it('should update master mixer in allMixers and masterMixers arrays', () => {
+ const stateWithMixers = {
+ ...initialState,
+ masterMixers: [
+ { id: 1, rid: 'rid1', volume: 0.5 },
+ { id: 2, rid: 'rid2', volume: 0.7 }
+ ],
+ allMixers: {
+ 'M1': { id: 1, rid: 'rid1', volume: 0.5 },
+ 'M2': { id: 2, rid: 'rid2', volume: 0.7 }
+ }
+ };
+
+ const actual = mixersReducer(
+ stateWithMixers,
+ updateMixer({ mixerId: 1, mode: true, updates: { volume: 0.8 } })
+ );
+
+ expect(actual.allMixers['M1'].volume).toBe(0.8);
+ expect(actual.masterMixers[0].volume).toBe(0.8);
+ });
+
+ it('should update personal mixer in allMixers and personalMixers arrays', () => {
+ const stateWithMixers = {
+ ...initialState,
+ personalMixers: [
+ { id: 1, rid: 'rid1', volume: 0.5 },
+ { id: 2, rid: 'rid2', volume: 0.7 }
+ ],
+ allMixers: {
+ 'P1': { id: 1, rid: 'rid1', volume: 0.5 },
+ 'P2': { id: 2, rid: 'rid2', volume: 0.7 }
+ }
+ };
+
+ const actual = mixersReducer(
+ stateWithMixers,
+ updateMixer({ mixerId: 1, mode: false, updates: { volume: 0.9 } })
+ );
+
+ expect(actual.allMixers['P1'].volume).toBe(0.9);
+ expect(actual.personalMixers[0].volume).toBe(0.9);
+ });
+
+ it('should not fail when updating non-existent mixer', () => {
+ const actual = mixersReducer(
+ initialState,
+ updateMixer({ mixerId: 999, mode: true, updates: { volume: 0.5 } })
+ );
+
+ expect(actual).toEqual(initialState);
+ });
+ });
+
+ describe('metronome', () => {
+ it('should handle setMetronome with metronome object', () => {
+ const metronome = { bpm: 120, sound: 'Beep', meter: 1, mode: 0 };
+ const actual = mixersReducer(initialState, setMetronome(metronome));
+
+ expect(actual.metronome).toEqual(metronome);
+ expect(actual.mediaSummary.metronomeOpen).toBe(true);
+ });
+
+ it('should handle setMetronome with null (close metronome)', () => {
+ const stateWithMetronome = {
+ ...initialState,
+ metronome: { bpm: 120 },
+ mediaSummary: { ...initialState.mediaSummary, metronomeOpen: true }
+ };
+
+ const actual = mixersReducer(stateWithMetronome, setMetronome(null));
+
+ expect(actual.metronome).toBeNull();
+ expect(actual.mediaSummary.metronomeOpen).toBe(false);
+ });
+
+ it('should handle setMetronomeSettings', () => {
+ const actual = mixersReducer(
+ initialState,
+ setMetronomeSettings({ tempo: 140, sound: 'Click', cricket: true })
+ );
+
+ expect(actual.metronomeSettings.tempo).toBe(140);
+ expect(actual.metronomeSettings.sound).toBe('Click');
+ expect(actual.metronomeSettings.cricket).toBe(true);
+ });
+
+ it('should handle partial metronome settings update', () => {
+ const actual = mixersReducer(
+ initialState,
+ setMetronomeSettings({ tempo: 160 })
+ );
+
+ expect(actual.metronomeSettings.tempo).toBe(160);
+ expect(actual.metronomeSettings.sound).toBe('Beep'); // Unchanged
+ expect(actual.metronomeSettings.cricket).toBe(false); // Unchanged
+ });
+ });
+
+ describe('media summary', () => {
+ it('should handle updateMediaSummary (partial update)', () => {
+ const actual = mixersReducer(
+ initialState,
+ updateMediaSummary({ backingTrackOpen: true, userNeedsMediaControls: true })
+ );
+
+ expect(actual.mediaSummary.backingTrackOpen).toBe(true);
+ expect(actual.mediaSummary.userNeedsMediaControls).toBe(true);
+ expect(actual.mediaSummary.jamTrackOpen).toBe(false); // Unchanged
+ });
+
+ it('should handle setMediaSummary (full replacement)', () => {
+ const newMediaSummary = {
+ mediaOpen: true,
+ backingTrackOpen: true,
+ jamTrackOpen: false,
+ recordingOpen: false,
+ metronomeOpen: false,
+ isOpener: true,
+ userNeedsMediaControls: true,
+ jamTrack: { id: 123, name: 'Test Track' }
+ };
+
+ const actual = mixersReducer(initialState, setMediaSummary(newMediaSummary));
+ expect(actual.mediaSummary).toEqual(newMediaSummary);
+ });
+ });
+
+ describe('chat, broadcast, recording mixers', () => {
+ it('should handle setChatMixer', () => {
+ const chatMixer = { id: 1, name: 'Chat Mixer' };
+ const actual = mixersReducer(initialState, setChatMixer(chatMixer));
+ expect(actual.chatMixer).toEqual(chatMixer);
+ });
+
+ it('should handle setBroadcastMixer', () => {
+ const broadcastMixer = { id: 2, name: 'Broadcast Mixer' };
+ const actual = mixersReducer(initialState, setBroadcastMixer(broadcastMixer));
+ expect(actual.broadcastMixer).toEqual(broadcastMixer);
+ });
+
+ it('should handle setRecordingMixer', () => {
+ const recordingMixer = { id: 3, name: 'Recording Mixer' };
+ const actual = mixersReducer(initialState, setRecordingMixer(recordingMixer));
+ expect(actual.recordingMixer).toEqual(recordingMixer);
+ });
+ });
+
+ describe('simulated category mixers', () => {
+ it('should handle setSimulatedMusicCategoryMixers', () => {
+ const mixers = { PERSONAL: { volume: 0.8 }, MASTER: { volume: 0.9 } };
+ const actual = mixersReducer(initialState, setSimulatedMusicCategoryMixers(mixers));
+ expect(actual.simulatedMusicCategoryMixers).toEqual(mixers);
+ });
+
+ it('should handle setSimulatedChatCategoryMixers', () => {
+ const mixers = { PERSONAL: { volume: 0.7 }, MASTER: { volume: 0.8 } };
+ const actual = mixersReducer(initialState, setSimulatedChatCategoryMixers(mixers));
+ expect(actual.simulatedChatCategoryMixers).toEqual(mixers);
+ });
+ });
+
+ describe('peer management', () => {
+ it('should handle addNoAudioUser', () => {
+ const actual = mixersReducer(initialState, addNoAudioUser('user123'));
+ expect(actual.noAudioUsers['user123']).toBe(true);
+ });
+
+ it('should handle removeNoAudioUser', () => {
+ const stateWithNoAudioUser = {
+ ...initialState,
+ noAudioUsers: { 'user123': true, 'user456': true }
+ };
+
+ const actual = mixersReducer(stateWithNoAudioUser, removeNoAudioUser('user123'));
+ expect(actual.noAudioUsers['user123']).toBeUndefined();
+ expect(actual.noAudioUsers['user456']).toBe(true);
+ });
+
+ it('should handle setNoAudioUsers', () => {
+ const noAudioUsers = { 'user1': true, 'user2': true };
+ const actual = mixersReducer(initialState, setNoAudioUsers(noAudioUsers));
+ expect(actual.noAudioUsers).toEqual(noAudioUsers);
+ });
+
+ it('should handle setClientsWithAudioOverride', () => {
+ const clients = { 'client1': true, 'client2': false };
+ const actual = mixersReducer(initialState, setClientsWithAudioOverride(clients));
+ expect(actual.clientsWithAudioOverride).toEqual(clients);
+ });
+
+ it('should handle setMissingMixerPeers', () => {
+ const peers = { 'peer1': true };
+ const actual = mixersReducer(initialState, setMissingMixerPeers(peers));
+ expect(actual.missingMixerPeers).toEqual(peers);
+ });
+
+ it('should handle setCheckingMissingPeers', () => {
+ const checking = { 'peer1': true };
+ const actual = mixersReducer(initialState, setCheckingMissingPeers(checking));
+ expect(actual.checkingMissingPeers).toEqual(checking);
+ });
+ });
+
+ describe('clearMixers', () => {
+ it('should reset to initial state', () => {
+ const stateWithData = {
+ ...initialState,
+ chatMixer: { id: 1 },
+ masterMixers: [{ id: 1 }],
+ personalMixers: [{ id: 2 }],
+ allMixers: { 'M1': { id: 1 } },
+ isReady: true,
+ metronome: { bpm: 120 }
+ };
+
+ const actual = mixersReducer(stateWithData, clearMixers());
+ expect(actual).toEqual(initialState);
+ });
+ });
+
+ describe('selectors', () => {
+ const mockState = {
+ mixers: {
+ ...initialState,
+ chatMixer: { id: 1, name: 'Chat' },
+ broadcastMixer: { id: 2, name: 'Broadcast' },
+ masterMixers: [{ id: 1 }],
+ personalMixers: [{ id: 2 }],
+ allMixers: { 'M1': { id: 1 }, 'P2': { id: 2 } },
+ mixersByResourceId: { 'rid1': { master: { id: 1 }, personal: { id: 2 } } },
+ mixersByTrackId: { '1': { master: { id: 1 }, personal: { id: 2 } } },
+ metronome: { bpm: 120 },
+ metronomeSettings: { tempo: 140, sound: 'Click', cricket: false },
+ mediaSummary: { backingTrackOpen: true },
+ isReady: true,
+ backingTrackMixers: [{ id: 3 }],
+ noAudioUsers: { 'user1': true }
+ }
+ };
+
+ it('should select all mixers', () => {
+ expect(selectAllMixers(mockState)).toEqual({ 'M1': { id: 1 }, 'P2': { id: 2 } });
+ });
+
+ it('should select master mixers', () => {
+ expect(selectMasterMixers(mockState)).toEqual([{ id: 1 }]);
+ });
+
+ it('should select personal mixers', () => {
+ expect(selectPersonalMixers(mockState)).toEqual([{ id: 2 }]);
+ });
+
+ it('should select mixers by resource ID', () => {
+ expect(selectMixersByResourceId(mockState)).toEqual({ 'rid1': { master: { id: 1 }, personal: { id: 2 } } });
+ });
+
+ it('should select mixers by track ID', () => {
+ expect(selectMixersByTrackId(mockState)).toEqual({ '1': { master: { id: 1 }, personal: { id: 2 } } });
+ });
+
+ it('should select chat mixer', () => {
+ expect(selectChatMixer(mockState)).toEqual({ id: 1, name: 'Chat' });
+ });
+
+ it('should select broadcast mixer', () => {
+ expect(selectBroadcastMixer(mockState)).toEqual({ id: 2, name: 'Broadcast' });
+ });
+
+ it('should select metronome', () => {
+ expect(selectMetronome(mockState)).toEqual({ bpm: 120 });
+ });
+
+ it('should select metronome settings', () => {
+ expect(selectMetronomeSettings(mockState)).toEqual({ tempo: 140, sound: 'Click', cricket: false });
+ });
+
+ it('should select media summary', () => {
+ expect(selectMediaSummary(mockState)).toEqual({ backingTrackOpen: true });
+ });
+
+ it('should select mixers ready flag', () => {
+ expect(selectMixersReady(mockState)).toBe(true);
+ });
+
+ it('should select backing track mixers', () => {
+ expect(selectBackingTrackMixers(mockState)).toEqual([{ id: 3 }]);
+ });
+
+ it('should select no audio users', () => {
+ expect(selectNoAudioUsers(mockState)).toEqual({ 'user1': true });
+ });
+
+ it('should select specific mixer by ID and mode', () => {
+ expect(selectMixer(1, true)(mockState)).toEqual({ id: 1 });
+ expect(selectMixer(2, false)(mockState)).toEqual({ id: 2 });
+ });
+
+ it('should select mixer pair by resource ID', () => {
+ expect(selectMixerPairByResourceId('rid1')(mockState)).toEqual({ master: { id: 1 }, personal: { id: 2 } });
+ });
+
+ it('should select mixer pair by track ID', () => {
+ expect(selectMixerPairByTrackId('1')(mockState)).toEqual({ master: { id: 1 }, personal: { id: 2 } });
+ });
+ });
+});
diff --git a/jam-ui/src/store/features/__tests__/sessionUISlice.test.js b/jam-ui/src/store/features/__tests__/sessionUISlice.test.js
index eb478868b..b4b6f2d8f 100644
--- a/jam-ui/src/store/features/__tests__/sessionUISlice.test.js
+++ b/jam-ui/src/store/features/__tests__/sessionUISlice.test.js
@@ -10,13 +10,36 @@ import sessionUIReducer, {
toggleParticipantVideos,
setShowMixer,
toggleMixer,
+ // Phase 5: Media UI actions
+ setShowMyMixes,
+ toggleMyMixes,
+ setShowCustomMixes,
+ toggleCustomMixes,
+ setEditingMixdownId,
+ setCreatingMixdown,
+ setCreateMixdownErrors,
+ // Phase 5: Mixer UI actions
+ setMixMode,
+ toggleMixMode,
+ setCurrentMixerRange,
resetUI,
selectModal,
selectAllModals,
selectIsAnyModalOpen,
selectPanel,
selectParticipantLayout,
- selectShowMixer
+ selectShowMixer,
+ // Phase 5: Media UI selectors
+ selectShowMyMixes,
+ selectShowCustomMixes,
+ selectEditingMixdownId,
+ selectCreatingMixdown,
+ selectCreateMixdownErrors,
+ selectMediaUI,
+ // Phase 5: Mixer UI selectors
+ selectMixMode,
+ selectCurrentMixerRange,
+ selectMixerUI
} from '../sessionUISlice';
describe('sessionUISlice', () => {
@@ -44,6 +67,22 @@ describe('sessionUISlice', () => {
showParticipantVideos: true,
showMixer: false,
sidebarCollapsed: false
+ },
+ // Phase 5: Media UI state
+ mediaUI: {
+ showMyMixes: false,
+ showCustomMixes: false,
+ editingMixdownId: null,
+ creatingMixdown: false,
+ createMixdownErrors: null
+ },
+ // Phase 5: Mixer UI state
+ mixerUI: {
+ mixMode: 'PERSONAL',
+ currentMixerRange: {
+ min: null,
+ max: null
+ }
}
};
@@ -281,4 +320,179 @@ describe('sessionUISlice', () => {
expect(selectShowMixer(mockState)).toBe(true);
});
});
+
+ // Phase 5: Media UI tests
+ describe('media UI actions', () => {
+ it('should handle setShowMyMixes', () => {
+ const actual = sessionUIReducer(initialState, setShowMyMixes(true));
+ expect(actual.mediaUI.showMyMixes).toBe(true);
+ });
+
+ it('should handle toggleMyMixes', () => {
+ const actual1 = sessionUIReducer(initialState, toggleMyMixes());
+ expect(actual1.mediaUI.showMyMixes).toBe(true);
+
+ const actual2 = sessionUIReducer(actual1, toggleMyMixes());
+ expect(actual2.mediaUI.showMyMixes).toBe(false);
+ });
+
+ it('should handle setShowCustomMixes', () => {
+ const actual = sessionUIReducer(initialState, setShowCustomMixes(true));
+ expect(actual.mediaUI.showCustomMixes).toBe(true);
+ });
+
+ it('should handle toggleCustomMixes', () => {
+ const actual1 = sessionUIReducer(initialState, toggleCustomMixes());
+ expect(actual1.mediaUI.showCustomMixes).toBe(true);
+
+ const actual2 = sessionUIReducer(actual1, toggleCustomMixes());
+ expect(actual2.mediaUI.showCustomMixes).toBe(false);
+ });
+
+ it('should handle setEditingMixdownId', () => {
+ const actual = sessionUIReducer(initialState, setEditingMixdownId('mixdown-123'));
+ expect(actual.mediaUI.editingMixdownId).toBe('mixdown-123');
+ });
+
+ it('should handle setCreatingMixdown', () => {
+ const actual = sessionUIReducer(initialState, setCreatingMixdown(true));
+ expect(actual.mediaUI.creatingMixdown).toBe(true);
+ });
+
+ it('should handle setCreateMixdownErrors', () => {
+ const errors = { name: 'Name is required' };
+ const actual = sessionUIReducer(initialState, setCreateMixdownErrors(errors));
+ expect(actual.mediaUI.createMixdownErrors).toEqual(errors);
+ });
+
+ it('should clear mixdown errors with null', () => {
+ const stateWithErrors = {
+ ...initialState,
+ mediaUI: { ...initialState.mediaUI, createMixdownErrors: { name: 'Error' } }
+ };
+
+ const actual = sessionUIReducer(stateWithErrors, setCreateMixdownErrors(null));
+ expect(actual.mediaUI.createMixdownErrors).toBeNull();
+ });
+ });
+
+ // Phase 5: Mixer UI tests
+ describe('mixer UI actions', () => {
+ it('should handle setMixMode', () => {
+ const actual = sessionUIReducer(initialState, setMixMode('MASTER'));
+ expect(actual.mixerUI.mixMode).toBe('MASTER');
+ });
+
+ it('should handle toggleMixMode from PERSONAL to MASTER', () => {
+ const actual = sessionUIReducer(initialState, toggleMixMode());
+ expect(actual.mixerUI.mixMode).toBe('MASTER');
+ });
+
+ it('should handle toggleMixMode from MASTER to PERSONAL', () => {
+ const stateWithMaster = {
+ ...initialState,
+ mixerUI: { ...initialState.mixerUI, mixMode: 'MASTER' }
+ };
+
+ const actual = sessionUIReducer(stateWithMaster, toggleMixMode());
+ expect(actual.mixerUI.mixMode).toBe('PERSONAL');
+ });
+
+ it('should handle setCurrentMixerRange', () => {
+ const range = { min: 0, max: 100 };
+ const actual = sessionUIReducer(initialState, setCurrentMixerRange(range));
+ expect(actual.mixerUI.currentMixerRange).toEqual(range);
+ });
+
+ it('should handle partial mixer range update', () => {
+ const stateWithRange = {
+ ...initialState,
+ mixerUI: { ...initialState.mixerUI, currentMixerRange: { min: 10, max: 90 } }
+ };
+
+ const actual = sessionUIReducer(stateWithRange, setCurrentMixerRange({ min: 10, max: 90 }));
+ expect(actual.mixerUI.currentMixerRange.min).toBe(10);
+ expect(actual.mixerUI.currentMixerRange.max).toBe(90);
+ });
+ });
+
+ // Phase 5: Extended resetUI test
+ describe('resetUI with Phase 5 state', () => {
+ it('should reset media UI and mixer UI to initial state', () => {
+ const modifiedState = {
+ ...initialState,
+ mediaUI: {
+ showMyMixes: true,
+ showCustomMixes: true,
+ editingMixdownId: 'mix-456',
+ creatingMixdown: true,
+ createMixdownErrors: { error: 'Some error' }
+ },
+ mixerUI: {
+ mixMode: 'MASTER',
+ currentMixerRange: { min: 10, max: 90 }
+ }
+ };
+
+ const actual = sessionUIReducer(modifiedState, resetUI());
+ expect(actual.mediaUI).toEqual(initialState.mediaUI);
+ expect(actual.mixerUI).toEqual(initialState.mixerUI);
+ });
+ });
+
+ // Phase 5: Media UI selectors
+ describe('media UI selectors', () => {
+ const mockStateWithPhase5 = {
+ sessionUI: {
+ ...initialState,
+ mediaUI: {
+ showMyMixes: true,
+ showCustomMixes: false,
+ editingMixdownId: 'mix-789',
+ creatingMixdown: true,
+ createMixdownErrors: { name: 'Required' }
+ },
+ mixerUI: {
+ mixMode: 'MASTER',
+ currentMixerRange: { min: 20, max: 80 }
+ }
+ }
+ };
+
+ it('selectShowMyMixes should return correct value', () => {
+ expect(selectShowMyMixes(mockStateWithPhase5)).toBe(true);
+ });
+
+ it('selectShowCustomMixes should return correct value', () => {
+ expect(selectShowCustomMixes(mockStateWithPhase5)).toBe(false);
+ });
+
+ it('selectEditingMixdownId should return correct value', () => {
+ expect(selectEditingMixdownId(mockStateWithPhase5)).toBe('mix-789');
+ });
+
+ it('selectCreatingMixdown should return correct value', () => {
+ expect(selectCreatingMixdown(mockStateWithPhase5)).toBe(true);
+ });
+
+ it('selectCreateMixdownErrors should return correct value', () => {
+ expect(selectCreateMixdownErrors(mockStateWithPhase5)).toEqual({ name: 'Required' });
+ });
+
+ it('selectMediaUI should return entire mediaUI object', () => {
+ expect(selectMediaUI(mockStateWithPhase5)).toEqual(mockStateWithPhase5.sessionUI.mediaUI);
+ });
+
+ it('selectMixMode should return correct mix mode', () => {
+ expect(selectMixMode(mockStateWithPhase5)).toBe('MASTER');
+ });
+
+ it('selectCurrentMixerRange should return correct range', () => {
+ expect(selectCurrentMixerRange(mockStateWithPhase5)).toEqual({ min: 20, max: 80 });
+ });
+
+ it('selectMixerUI should return entire mixerUI object', () => {
+ expect(selectMixerUI(mockStateWithPhase5)).toEqual(mockStateWithPhase5.sessionUI.mixerUI);
+ });
+ });
});
diff --git a/jam-ui/src/store/features/mediaSlice.js b/jam-ui/src/store/features/mediaSlice.js
new file mode 100644
index 000000000..a017daa0d
--- /dev/null
+++ b/jam-ui/src/store/features/mediaSlice.js
@@ -0,0 +1,183 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+
+// Async thunks for media actions
+export const openBackingTrack = createAsyncThunk(
+ 'media/openBackingTrack',
+ async ({ file, jamClient }, { rejectWithValue }) => {
+ try {
+ await jamClient.SessionOpenBackingTrackFile(file, false);
+ return { file };
+ } catch (error) {
+ return rejectWithValue(error.message);
+ }
+ }
+);
+
+export const loadJamTrack = createAsyncThunk(
+ 'media/loadJamTrack',
+ async ({ jamTrack, jamClient }, { rejectWithValue }) => {
+ try {
+ // Load JMep data if available (matches MediaContext:163-170 logic)
+ if (jamTrack.jmep) {
+ const sampleRate = await jamClient.GetSampleRate();
+ const sampleRateForFilename = sampleRate === 48 ? '48' : '44';
+ const fqId = `${jamTrack.id}-${sampleRateForFilename}`;
+ await jamClient.JamTrackLoadJmep(fqId, jamTrack.jmep);
+ }
+
+ // Play/load the jamtrack
+ const result = await jamClient.JamTrackPlay(jamTrack.id);
+
+ if (!result) {
+ throw new Error('Unable to open JamTrack');
+ }
+
+ return { jamTrack, result };
+ } catch (error) {
+ return rejectWithValue(error.message);
+ }
+ }
+);
+
+export const closeMedia = createAsyncThunk(
+ 'media/closeMedia',
+ async ({ force = false, jamClient }, { rejectWithValue }) => {
+ try {
+ await jamClient.SessionCloseMedia(force);
+ return null;
+ } catch (error) {
+ return rejectWithValue(error.message);
+ }
+ }
+);
+
+const initialState = {
+ // Media arrays (resolved/enriched data from mixer system)
+ // Format matches useMixerHelper resolved data structures
+ backingTracks: [], // [{ isOpener, shortFilename, instrumentIcon, photoUrl, showLoop, track, mixers }]
+ jamTracks: [], // [{ name, trackName, part, isOpener, instrumentIcon, track, mixers }]
+ recordedTracks: [], // [{ recordingName, isOpener, userName, instrumentIcon, track, mixers }]
+
+ // JamTrack state (real-time playback state from native client)
+ jamTrackState: {}, // Real-time jamTrack playback state from WebSocket JAM_TRACK_CHANGES
+ downloadingJamTrack: false,
+
+ // Loading states for async operations
+ loading: {
+ backingTrack: false,
+ jamTrack: false,
+ closing: false
+ },
+
+ error: null
+};
+
+export const mediaSlice = createSlice({
+ name: 'media',
+ initialState,
+ reducers: {
+ // Set resolved media data (called from useMixerHelper after processing)
+ setBackingTracks: (state, action) => {
+ state.backingTracks = action.payload;
+ },
+
+ setJamTracks: (state, action) => {
+ state.jamTracks = action.payload;
+ },
+
+ setRecordedTracks: (state, action) => {
+ state.recordedTracks = action.payload;
+ },
+
+ // JamTrack state updates (from WebSocket JAM_TRACK_CHANGES messages)
+ updateJamTrackState: (state, action) => {
+ state.jamTrackState = { ...state.jamTrackState, ...action.payload };
+ },
+
+ clearJamTrackState: (state) => {
+ state.jamTrackState = {};
+ },
+
+ // Clear all media (called on session end or media close)
+ clearAllMedia: (state) => {
+ state.backingTracks = [];
+ state.jamTracks = [];
+ state.recordedTracks = [];
+ state.jamTrackState = {};
+ state.downloadingJamTrack = false;
+ state.error = null;
+ }
+ },
+
+ extraReducers: (builder) => {
+ builder
+ // Open backing track
+ .addCase(openBackingTrack.pending, (state) => {
+ state.loading.backingTrack = true;
+ state.error = null;
+ })
+ .addCase(openBackingTrack.fulfilled, (state) => {
+ state.loading.backingTrack = false;
+ // Note: backingTracks array updated by MIXER_CHANGES WebSocket message
+ // which triggers useMixerHelper to resolve and dispatch setBackingTracks
+ })
+ .addCase(openBackingTrack.rejected, (state, action) => {
+ state.loading.backingTrack = false;
+ state.error = action.payload;
+ })
+
+ // Load jam track
+ .addCase(loadJamTrack.pending, (state) => {
+ state.loading.jamTrack = true;
+ state.downloadingJamTrack = true;
+ state.error = null;
+ })
+ .addCase(loadJamTrack.fulfilled, (state) => {
+ state.loading.jamTrack = false;
+ state.downloadingJamTrack = false;
+ // Note: jamTracks array updated by MIXER_CHANGES WebSocket message
+ })
+ .addCase(loadJamTrack.rejected, (state, action) => {
+ state.loading.jamTrack = false;
+ state.downloadingJamTrack = false;
+ state.error = action.payload;
+ })
+
+ // Close media
+ .addCase(closeMedia.pending, (state) => {
+ state.loading.closing = true;
+ })
+ .addCase(closeMedia.fulfilled, (state) => {
+ state.loading.closing = false;
+ // Clear all media data
+ state.backingTracks = [];
+ state.jamTracks = [];
+ state.recordedTracks = [];
+ state.jamTrackState = {};
+ })
+ .addCase(closeMedia.rejected, (state, action) => {
+ state.loading.closing = false;
+ state.error = action.payload;
+ });
+ }
+});
+
+export const {
+ setBackingTracks,
+ setJamTracks,
+ setRecordedTracks,
+ updateJamTrackState,
+ clearJamTrackState,
+ clearAllMedia
+} = mediaSlice.actions;
+
+export default mediaSlice.reducer;
+
+// Selectors
+export const selectBackingTracks = (state) => state.media.backingTracks;
+export const selectJamTracks = (state) => state.media.jamTracks;
+export const selectRecordedTracks = (state) => state.media.recordedTracks;
+export const selectJamTrackState = (state) => state.media.jamTrackState;
+export const selectDownloadingJamTrack = (state) => state.media.downloadingJamTrack;
+export const selectMediaLoading = (state) => state.media.loading;
+export const selectMediaError = (state) => state.media.error;
diff --git a/jam-ui/src/store/features/mixersSlice.js b/jam-ui/src/store/features/mixersSlice.js
new file mode 100644
index 000000000..149f5d122
--- /dev/null
+++ b/jam-ui/src/store/features/mixersSlice.js
@@ -0,0 +1,315 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+const initialState = {
+ // Core mixer collections
+ chatMixer: null,
+ broadcastMixer: null,
+ recordingMixer: null,
+
+ // Mixer arrays by type (populated by groupMixersByType)
+ recordingTrackMixers: [],
+ backingTrackMixers: [],
+ jamTrackMixers: [],
+ metronomeTrackMixers: [],
+ adhocTrackMixers: [],
+
+ // Master and personal mixer arrays (source data from jamClient)
+ masterMixers: [],
+ personalMixers: [],
+
+ // Lookup tables (computed by organizeMixers reducer)
+ allMixers: {}, // Format: { 'M123': mixer, 'P456': mixer }
+ mixersByResourceId: {}, // Format: { 'rid': { master: mixer, personal: mixer } }
+ mixersByTrackId: {}, // Format: { 'trackId': { master: mixer, personal: mixer } }
+
+ // Simulated/derived mixers for category controls
+ simulatedMusicCategoryMixers: {
+ PERSONAL: null,
+ MASTER: null
+ },
+ simulatedChatCategoryMixers: {
+ PERSONAL: null,
+ MASTER: null
+ },
+
+ // Metronome state
+ metronome: null, // Current metronome mixer object
+ metronomeSettings: {
+ tempo: 120,
+ sound: "Beep",
+ cricket: false
+ },
+
+ // Media summary (open/closed states for media types)
+ // This tracks what media is currently open in the session
+ mediaSummary: {
+ mediaOpen: false,
+ backingTrackOpen: false,
+ jamTrackOpen: false,
+ recordingOpen: false,
+ metronomeOpen: false,
+ isOpener: false,
+ userNeedsMediaControls: false,
+ jamTrack: null
+ },
+
+ // Peer state (for managing missing audio users)
+ noAudioUsers: {},
+ clientsWithAudioOverride: {},
+ missingMixerPeers: {},
+ checkingMissingPeers: {},
+
+ // Ready state (indicates mixers have been organized and are ready to use)
+ isReady: false,
+
+ // Loading/errors
+ loading: false,
+ error: null
+};
+
+export const mixersSlice = createSlice({
+ name: 'mixers',
+ initialState,
+ reducers: {
+ // Initialize from jamClient.SessionGetAllControlState
+ setMasterMixers: (state, action) => {
+ state.masterMixers = action.payload;
+ },
+
+ setPersonalMixers: (state, action) => {
+ state.personalMixers = action.payload;
+ },
+
+ // Organize mixers - builds lookup tables (matches useMixerHelper organizeMixers logic)
+ // This should be called after master/personal mixers are set
+ organizeMixers: (state) => {
+ const newAllMixers = {};
+ const newMixersByResourceId = {};
+ const newMixersByTrackId = {};
+
+ // Process master mixers
+ for (const masterMixer of state.masterMixers) {
+ newAllMixers['M' + masterMixer.id] = masterMixer;
+
+ const mixerPair = {};
+ newMixersByResourceId[masterMixer.rid] = mixerPair;
+ newMixersByTrackId[masterMixer.id] = mixerPair;
+ mixerPair.master = masterMixer;
+ }
+
+ // Process personal mixers
+ for (const personalMixer of state.personalMixers) {
+ newAllMixers['P' + personalMixer.id] = personalMixer;
+
+ let mixerPair = newMixersByResourceId[personalMixer.rid];
+ if (!mixerPair) {
+ // Create new pair if no master exists (e.g., MonitorGroup)
+ mixerPair = {};
+ newMixersByResourceId[personalMixer.rid] = mixerPair;
+ }
+ newMixersByTrackId[personalMixer.id] = mixerPair;
+ mixerPair.personal = personalMixer;
+ }
+
+ // Update state
+ state.allMixers = { ...state.allMixers, ...newAllMixers };
+ state.mixersByResourceId = { ...state.mixersByResourceId, ...newMixersByResourceId };
+ state.mixersByTrackId = { ...state.mixersByTrackId, ...newMixersByTrackId };
+ state.isReady = true;
+ },
+
+ // Group mixers by type - categorizes mixers into recording, backing, jam, metronome, adhoc
+ // This complex logic will be implemented in useMixerHelper Redux version
+ // For now, just accept the categorized arrays
+ setRecordingTrackMixers: (state, action) => {
+ state.recordingTrackMixers = action.payload;
+ },
+
+ setBackingTrackMixers: (state, action) => {
+ state.backingTrackMixers = action.payload;
+ },
+
+ setJamTrackMixers: (state, action) => {
+ state.jamTrackMixers = action.payload;
+ },
+
+ setMetronomeTrackMixers: (state, action) => {
+ state.metronomeTrackMixers = action.payload;
+ },
+
+ setAdhocTrackMixers: (state, action) => {
+ state.adhocTrackMixers = action.payload;
+ },
+
+ // Update individual mixer (for real-time updates from jamClient)
+ updateMixer: (state, action) => {
+ const { mixerId, mode, updates } = action.payload;
+ const key = (mode ? 'M' : 'P') + mixerId;
+
+ if (state.allMixers[key]) {
+ state.allMixers[key] = { ...state.allMixers[key], ...updates };
+
+ // Also update in master/personal arrays
+ if (mode) {
+ const index = state.masterMixers.findIndex(m => m.id === mixerId);
+ if (index !== -1) {
+ state.masterMixers[index] = { ...state.masterMixers[index], ...updates };
+ }
+ } else {
+ const index = state.personalMixers.findIndex(m => m.id === mixerId);
+ if (index !== -1) {
+ state.personalMixers[index] = { ...state.personalMixers[index], ...updates };
+ }
+ }
+ }
+ },
+
+ // Metronome
+ setMetronome: (state, action) => {
+ state.metronome = action.payload;
+ state.mediaSummary.metronomeOpen = !!action.payload;
+ },
+
+ setMetronomeSettings: (state, action) => {
+ state.metronomeSettings = { ...state.metronomeSettings, ...action.payload };
+ },
+
+ // Media summary updates
+ updateMediaSummary: (state, action) => {
+ state.mediaSummary = { ...state.mediaSummary, ...action.payload };
+ },
+
+ setMediaSummary: (state, action) => {
+ state.mediaSummary = action.payload;
+ },
+
+ // Chat/broadcast mixers
+ setChatMixer: (state, action) => {
+ state.chatMixer = action.payload;
+ },
+
+ setBroadcastMixer: (state, action) => {
+ state.broadcastMixer = action.payload;
+ },
+
+ setRecordingMixer: (state, action) => {
+ state.recordingMixer = action.payload;
+ },
+
+ // Simulated mixers (for category controls)
+ setSimulatedMusicCategoryMixers: (state, action) => {
+ state.simulatedMusicCategoryMixers = action.payload;
+ },
+
+ setSimulatedChatCategoryMixers: (state, action) => {
+ state.simulatedChatCategoryMixers = action.payload;
+ },
+
+ // Peer management
+ addNoAudioUser: (state, action) => {
+ state.noAudioUsers[action.payload] = true;
+ },
+
+ removeNoAudioUser: (state, action) => {
+ delete state.noAudioUsers[action.payload];
+ },
+
+ setNoAudioUsers: (state, action) => {
+ state.noAudioUsers = action.payload;
+ },
+
+ setClientsWithAudioOverride: (state, action) => {
+ state.clientsWithAudioOverride = action.payload;
+ },
+
+ setMissingMixerPeers: (state, action) => {
+ state.missingMixerPeers = action.payload;
+ },
+
+ setCheckingMissingPeers: (state, action) => {
+ state.checkingMissingPeers = action.payload;
+ },
+
+ // Clear on session end
+ clearMixers: (state) => {
+ return { ...initialState };
+ }
+ }
+});
+
+export const {
+ setMasterMixers,
+ setPersonalMixers,
+ organizeMixers,
+ setRecordingTrackMixers,
+ setBackingTrackMixers,
+ setJamTrackMixers,
+ setMetronomeTrackMixers,
+ setAdhocTrackMixers,
+ updateMixer,
+ setMetronome,
+ setMetronomeSettings,
+ updateMediaSummary,
+ setMediaSummary,
+ setChatMixer,
+ setBroadcastMixer,
+ setRecordingMixer,
+ setSimulatedMusicCategoryMixers,
+ setSimulatedChatCategoryMixers,
+ addNoAudioUser,
+ removeNoAudioUser,
+ setNoAudioUsers,
+ setClientsWithAudioOverride,
+ setMissingMixerPeers,
+ setCheckingMissingPeers,
+ clearMixers
+} = mixersSlice.actions;
+
+export default mixersSlice.reducer;
+
+// Selectors
+export const selectAllMixers = (state) => state.mixers.allMixers;
+export const selectMasterMixers = (state) => state.mixers.masterMixers;
+export const selectPersonalMixers = (state) => state.mixers.personalMixers;
+export const selectMixersByResourceId = (state) => state.mixers.mixersByResourceId;
+export const selectMixersByTrackId = (state) => state.mixers.mixersByTrackId;
+
+export const selectChatMixer = (state) => state.mixers.chatMixer;
+export const selectBroadcastMixer = (state) => state.mixers.broadcastMixer;
+export const selectRecordingMixer = (state) => state.mixers.recordingMixer;
+
+export const selectMetronome = (state) => state.mixers.metronome;
+export const selectMetronomeSettings = (state) => state.mixers.metronomeSettings;
+export const selectMediaSummary = (state) => state.mixers.mediaSummary;
+
+export const selectMixersReady = (state) => state.mixers.isReady;
+
+export const selectBackingTrackMixers = (state) => state.mixers.backingTrackMixers;
+export const selectJamTrackMixers = (state) => state.mixers.jamTrackMixers;
+export const selectRecordingTrackMixers = (state) => state.mixers.recordingTrackMixers;
+export const selectMetronomeTrackMixers = (state) => state.mixers.metronomeTrackMixers;
+export const selectAdhocTrackMixers = (state) => state.mixers.adhocTrackMixers;
+
+export const selectSimulatedMusicCategoryMixers = (state) => state.mixers.simulatedMusicCategoryMixers;
+export const selectSimulatedChatCategoryMixers = (state) => state.mixers.simulatedChatCategoryMixers;
+
+export const selectNoAudioUsers = (state) => state.mixers.noAudioUsers;
+export const selectClientsWithAudioOverride = (state) => state.mixers.clientsWithAudioOverride;
+export const selectMissingMixerPeers = (state) => state.mixers.missingMixerPeers;
+export const selectCheckingMissingPeers = (state) => state.mixers.checkingMissingPeers;
+
+// Computed selector for getting a specific mixer by ID and mode
+export const selectMixer = (mixerId, mode) => (state) => {
+ const key = (mode ? 'M' : 'P') + mixerId;
+ return state.mixers.allMixers[key];
+};
+
+// Computed selector for getting mixer pair by resource ID
+export const selectMixerPairByResourceId = (resourceId) => (state) => {
+ return state.mixers.mixersByResourceId[resourceId];
+};
+
+// Computed selector for getting mixer pair by track ID
+export const selectMixerPairByTrackId = (trackId) => (state) => {
+ return state.mixers.mixersByTrackId[trackId];
+};
diff --git a/jam-ui/src/store/features/sessionUISlice.js b/jam-ui/src/store/features/sessionUISlice.js
index 69e6c6844..aeeb3aae1 100644
--- a/jam-ui/src/store/features/sessionUISlice.js
+++ b/jam-ui/src/store/features/sessionUISlice.js
@@ -27,6 +27,24 @@ const initialState = {
showParticipantVideos: true,
showMixer: false,
sidebarCollapsed: false
+ },
+
+ // Media UI state (Phase 5: from MediaContext)
+ mediaUI: {
+ showMyMixes: false,
+ showCustomMixes: false,
+ editingMixdownId: null,
+ creatingMixdown: false,
+ createMixdownErrors: null
+ },
+
+ // Mixer UI state (Phase 5: from useMixerHelper/useMixerStore)
+ mixerUI: {
+ mixMode: 'PERSONAL', // 'PERSONAL' | 'MASTER'
+ currentMixerRange: {
+ min: null,
+ max: null
+ }
}
};
@@ -95,6 +113,48 @@ export const sessionUISlice = createSlice({
state.view.sidebarCollapsed = action.payload;
},
+ // Phase 5: Media UI reducers
+ setShowMyMixes: (state, action) => {
+ state.mediaUI.showMyMixes = action.payload;
+ },
+
+ toggleMyMixes: (state) => {
+ state.mediaUI.showMyMixes = !state.mediaUI.showMyMixes;
+ },
+
+ setShowCustomMixes: (state, action) => {
+ state.mediaUI.showCustomMixes = action.payload;
+ },
+
+ toggleCustomMixes: (state) => {
+ state.mediaUI.showCustomMixes = !state.mediaUI.showCustomMixes;
+ },
+
+ setEditingMixdownId: (state, action) => {
+ state.mediaUI.editingMixdownId = action.payload;
+ },
+
+ setCreatingMixdown: (state, action) => {
+ state.mediaUI.creatingMixdown = action.payload;
+ },
+
+ setCreateMixdownErrors: (state, action) => {
+ state.mediaUI.createMixdownErrors = action.payload;
+ },
+
+ // Phase 5: Mixer UI reducers
+ setMixMode: (state, action) => {
+ state.mixerUI.mixMode = action.payload;
+ },
+
+ toggleMixMode: (state) => {
+ state.mixerUI.mixMode = state.mixerUI.mixMode === 'PERSONAL' ? 'MASTER' : 'PERSONAL';
+ },
+
+ setCurrentMixerRange: (state, action) => {
+ state.mixerUI.currentMixerRange = action.payload;
+ },
+
// Reset UI state (useful when leaving session)
resetUI: (state) => {
return { ...initialState };
@@ -117,6 +177,18 @@ export const {
setShowMixer,
toggleSidebar,
setSidebarCollapsed,
+ // Phase 5: Media UI actions
+ setShowMyMixes,
+ toggleMyMixes,
+ setShowCustomMixes,
+ toggleCustomMixes,
+ setEditingMixdownId,
+ setCreatingMixdown,
+ setCreateMixdownErrors,
+ // Phase 5: Mixer UI actions
+ setMixMode,
+ toggleMixMode,
+ setCurrentMixerRange,
resetUI
} = sessionUISlice.actions;
@@ -135,3 +207,16 @@ export const selectShowParticipantVideos = (state) => state.sessionUI.view.showP
export const selectShowMixer = (state) => state.sessionUI.view.showMixer;
export const selectSidebarCollapsed = (state) => state.sessionUI.view.sidebarCollapsed;
export const selectViewPreferences = (state) => state.sessionUI.view;
+
+// Phase 5: Media UI selectors
+export const selectShowMyMixes = (state) => state.sessionUI.mediaUI.showMyMixes;
+export const selectShowCustomMixes = (state) => state.sessionUI.mediaUI.showCustomMixes;
+export const selectEditingMixdownId = (state) => state.sessionUI.mediaUI.editingMixdownId;
+export const selectCreatingMixdown = (state) => state.sessionUI.mediaUI.creatingMixdown;
+export const selectCreateMixdownErrors = (state) => state.sessionUI.mediaUI.createMixdownErrors;
+export const selectMediaUI = (state) => state.sessionUI.mediaUI;
+
+// Phase 5: Mixer UI selectors
+export const selectMixMode = (state) => state.sessionUI.mixerUI.mixMode;
+export const selectCurrentMixerRange = (state) => state.sessionUI.mixerUI.currentMixerRange;
+export const selectMixerUI = (state) => state.sessionUI.mixerUI;
diff --git a/jam-ui/src/store/store.js b/jam-ui/src/store/store.js
index ed840925b..665d91eba 100644
--- a/jam-ui/src/store/store.js
+++ b/jam-ui/src/store/store.js
@@ -13,6 +13,8 @@ import myJamTracksSlice from "./features/myJamTracksSlice"
import jamTrackSlice from "./features/jamTrackSlice"
import activeSessionReducer from "./features/activeSessionSlice"
import sessionUIReducer from "./features/sessionUISlice"
+import mixersReducer from "./features/mixersSlice"
+import mediaReducer from "./features/mediaSlice"
export default configureStore({
reducer: {
@@ -23,6 +25,8 @@ export default configureStore({
session: sessionReducer, // this is the slice that holds the session lists
activeSession: activeSessionReducer, // this is the slice that holds the currently active session
sessionUI: sessionUIReducer, // this is the slice that holds the session UI state (modals, panels)
+ mixers: mixersReducer, // Phase 5: mixer state (chat, broadcast, track mixers, metronome, media summary)
+ media: mediaReducer, // Phase 5: media data (backing tracks, jam tracks, recorded tracks)
latency: latencyReducer,
onlineMusician: onlineMusicianReducer,
lobbyChat: lobbyChatMessagesReducer,