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,