diff --git a/jam-ui/src/components/client/JKSessionOpenMenu.js b/jam-ui/src/components/client/JKSessionOpenMenu.js index 5e324994b..44f344952 100644 --- a/jam-ui/src/components/client/JKSessionOpenMenu.js +++ b/jam-ui/src/components/client/JKSessionOpenMenu.js @@ -1,6 +1,10 @@ import React, { useState, useRef, useEffect, useContext } from 'react'; import { createPortal } from 'react-dom'; import { useJamClient } from '../../context/JamClientContext'; +import { useMediaContext } from '../../context/MediaContext'; +import { useCurrentSessionContext } from '../../context/CurrentSessionContext'; +import { useAuth } from '../../context/UserAuth'; +import { toast } from 'react-toastify'; import openIcon from '../../assets/img/client/open.svg'; const JKSessionOpenMenu = ({ onBackingTrackSelected, onJamTrackSelected, onMetronomeSelected }) => { diff --git a/jam-ui/src/components/client/JKSessionScreen.js b/jam-ui/src/components/client/JKSessionScreen.js index 9f6ba293a..110a13032 100644 --- a/jam-ui/src/components/client/JKSessionScreen.js +++ b/jam-ui/src/components/client/JKSessionScreen.js @@ -15,6 +15,7 @@ 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 { dkeys } from '../../helpers/utils.js'; @@ -40,6 +41,7 @@ import JKSessionJamTrackStems from './JKSessionJamTrackStems.js'; import JKSessionOpenMenu from './JKSessionOpenMenu.js'; import WindowPortal from '../common/WindowPortal.js'; import JKSessionBackingTrackPlayer from './JKSessionBackingTrackPlayer.js'; +import JKPopupMediaControls from '../popups/JKPopupMediaControls.js'; import { SESSION_PRIVACY_MAP } from '../../helpers/globals.js'; import { toast } from 'react-toastify'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -82,6 +84,7 @@ const JKSessionScreen = () => { const { globalObject, metronomeState, closeMetronome, resetMetronome } = useGlobalContext(); const { getCurrentRecordingState, reset: resetRecordingState, currentlyRecording } = useRecordingHelpers(); const { SessionPageEnter } = useSessionUtils(); + const { mediaSummary, openBackingTrack, openMetronome, loadJamTrack } = useMediaContext(); // Use the session model hook const sessionModel = useSessionModel(app, server, null); // sessionScreen is null for now @@ -130,6 +133,10 @@ const JKSessionScreen = () => { const [showBackingTrackPopup, setShowBackingTrackPopup] = useState(false); const [backingTrackData, setBackingTrackData] = useState(null); + //state for media controls popup + const [showMediaControlsPopup, setShowMediaControlsPopup] = useState(false); + const [mediaControlsOpened, setMediaControlsOpened] = useState(false); + //state for jam track modal const [showJamTrackModal, setShowJamTrackModal] = useState(false); @@ -1218,6 +1225,24 @@ const JKSessionScreen = () => { toggle={() => setShowJamTrackModal(!showJamTrackModal)} onJamTrackSelect={handleJamTrackSelect} /> + + {/* Media Controls Popup - Only show when explicitly opened */} + {showMediaControlsPopup && ( + { + setShowMediaControlsPopup(false); + setMediaControlsOpened(false); + }} + windowFeatures="width=600,height=500,left=250,top=150,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no,addressbar=no" + title="Media Controls" + windowId="media-controls" + > + { + setShowMediaControlsPopup(false); + setMediaControlsOpened(false); + }} /> + + )} ) } diff --git a/jam-ui/src/components/common/WindowPortal.js b/jam-ui/src/components/common/WindowPortal.js index 6ba26555b..9c98e0ee5 100644 --- a/jam-ui/src/components/common/WindowPortal.js +++ b/jam-ui/src/components/common/WindowPortal.js @@ -1,14 +1,60 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import ReactDOM from 'react-dom'; -const WindowPortal = ({ children, onClose, windowFeatures = 'width=400,height=300,left=200,top=200,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no, addressbar=no', title = 'Backing Track' }) => { +const WindowPortal = ({ + children, + onClose, + windowFeatures = 'width=400,height=300,left=200,top=200,menubar=no,toolbar=no,status=no,scrollbars=yes,resizable=yes,location=no, addressbar=no', + title = 'Media Controls', + onWindowReady, + onWindowMessage, + windowId +}) => { const [externalWindow, setExternalWindow] = useState(null); const [isReady, setIsReady] = useState(false); const containerRef = useRef(null); + const messageHandlerRef = useRef(null); + + // Message handling + const handleMessage = useCallback((event) => { + // Only accept messages from our popup window + if (event.source === externalWindow) { + if (onWindowMessage) { + onWindowMessage(event.data); + } + } + }, [externalWindow, onWindowMessage]); + + // Send message to popup window + const sendMessage = useCallback((message) => { + if (externalWindow && !externalWindow.closed) { + externalWindow.postMessage(message, window.location.origin); + } + }, [externalWindow]); + + // Auto-resize window based on content + const resizeWindow = useCallback(() => { + if (!externalWindow || externalWindow.closed) return; + + const container = containerRef.current; + if (!container) return; + + const width = container.offsetWidth; + const height = container.offsetHeight; + + // Add some padding for window chrome + const chromePadding = 20; + const newWidth = Math.max(width + chromePadding, 350); + const newHeight = Math.max(height + chromePadding, 200); + + externalWindow.resizeTo(newWidth, newHeight); + }, [externalWindow]); useEffect(() => { - // Open new window - const newWindow = window.open('', '_blank', windowFeatures); + // DEBUG: Comment out window.open and use console.log to test for infinite loops + console.log('WindowPortal: Attempting to open window with features:', windowFeatures); + // const newWindow = window.open('', '_blank', windowFeatures); + const newWindow = null; // Temporarily disabled for debugging if (!newWindow) { console.error('Failed to open popup window - popup blocker may be active'); @@ -22,21 +68,38 @@ const WindowPortal = ({ children, onClose, windowFeatures = 'width=400,height=30 newWindow.document.body.style.padding = '0'; newWindow.document.body.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif'; newWindow.document.body.style.backgroundColor = '#f8f9fa'; + newWindow.document.body.style.overflow = 'hidden'; + + // Add window ID for identification + if (windowId) { + newWindow.windowId = windowId; + } // Create container div const container = newWindow.document.createElement('div'); container.style.width = '100vw'; container.style.height = '100vh'; + container.style.overflow = 'auto'; newWindow.document.body.appendChild(container); containerRef.current = container; setExternalWindow(newWindow); setIsReady(true); - // Handle window close + // Set up message handling + messageHandlerRef.current = handleMessage; + window.addEventListener('message', messageHandlerRef.current); + + // Notify parent that window is ready + if (onWindowReady) { + onWindowReady(newWindow, sendMessage); + } + + // Handle window close detection const checkClosed = setInterval(() => { if (newWindow.closed) { clearInterval(checkClosed); + window.removeEventListener('message', messageHandlerRef.current); onClose(); } }, 1000); @@ -48,16 +111,36 @@ const WindowPortal = ({ children, onClose, windowFeatures = 'width=400,height=30 } }; + // Handle popup window unload + const handlePopupUnload = () => { + onClose(); + }; + window.addEventListener('beforeunload', handleBeforeUnload); + newWindow.addEventListener('beforeunload', handlePopupUnload); + + // Initial resize after a short delay + setTimeout(resizeWindow, 100); return () => { clearInterval(checkClosed); window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('message', messageHandlerRef.current); if (newWindow && !newWindow.closed) { + newWindow.removeEventListener('beforeunload', handlePopupUnload); newWindow.close(); } }; - }, [windowFeatures]); + }, [windowFeatures, title, windowId, onClose, onWindowReady, handleMessage]); + + // Resize when children change + useEffect(() => { + if (isReady) { + // Delay resize to allow DOM updates + const timeoutId = setTimeout(resizeWindow, 50); + return () => clearTimeout(timeoutId); + } + }, [children, isReady, resizeWindow]); if (!isReady || !externalWindow || !containerRef.current) { return null; diff --git a/jam-ui/src/components/popups/JKPopupMediaControls.js b/jam-ui/src/components/popups/JKPopupMediaControls.js index 9275b3cb5..ae4523e5a 100644 --- a/jam-ui/src/components/popups/JKPopupMediaControls.js +++ b/jam-ui/src/components/popups/JKPopupMediaControls.js @@ -1,14 +1,352 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState, useCallback } from 'react'; +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'; -const JKPopupMediaControls = () => { - useEffect(() => { - console.log('JKPopupMediaControls mounted'); - alert('JKPopupMediaControls mounted'); - }, []) +const JKPopupMediaControls = ({ onClose }) => { + const { currentUser } = useAuth(); + const { jamClient } = useJamClient(); + const { + mediaSummary, + backingTracks, + jamTracks, + recordedTracks, + metronome, + jamTrackState, + downloadingJamTrack, + showMyMixes, + showCustomMixes, + editingMixdownId, + creatingMixdown, + createMixdownErrors, + closeMedia, + setShowMyMixes, + setShowCustomMixes, + setEditingMixdownId, + setCreatingMixdown, + setCreateMixdownErrors + } = useMediaContext(); + + const [time, setTime] = useState('0:00'); + const [isPlaying, setIsPlaying] = useState(false); + const [loopEnabled, setLoopEnabled] = useState(false); + + // Get file name helper + const getFileName = (file) => { + if (!file) return 'Unknown File'; + if (file.path) { + return file.path.split('/').pop().split('\\').pop(); + } + if (file.name) { + return file.name; + } + return 'Audio File'; + }; + + // Handle close + const handleClose = async () => { + try { + if (!mediaSummary.isOpener && !mediaSummary.metronomeOpen) { + console.log("Only opener can close media"); + return; + } + await closeMedia(); + if (onClose) onClose(); + } catch (error) { + console.error('Error closing media:', error); + } + }; + + // Handle metronome display + const handleShowMetronome = async () => { + try { + await jamClient.SessionShowNativeMetronomeGui(); + } catch (error) { + console.error('Error showing metronome:', error); + } + }; + + // Toggle mix sections + const toggleMyMixes = () => setShowMyMixes(!showMyMixes); + const toggleCustomMixes = () => setShowCustomMixes(!showCustomMixes); + + // JamTrack actions + const handleJamTrackPlay = async (jamTrack) => { + try { + await jamClient.JamTrackActivateNoMixdown(jamTrack); + } catch (error) { + console.error('Error playing jam track:', error); + } + }; + + const handleMixdownPlay = async (mixdown) => { + try { + setEditingMixdownId(null); + await jamClient.JamTrackActivateMixdown(mixdown); + } catch (error) { + console.error('Error playing mixdown:', error); + } + }; + + const handleMixdownEdit = (mixdown) => { + setEditingMixdownId(mixdown.id); + }; + + const handleMixdownSave = async (mixdown) => { + // Implementation for saving mixdown name + setEditingMixdownId(null); + }; + + const handleMixdownDelete = async (mixdown) => { + if (window.confirm("Delete this custom mix?")) { + try { + await jamClient.JamTrackDeleteMixdown(mixdown); + } catch (error) { + console.error('Error deleting mixdown:', error); + } + } + }; + + const handleDownloadMixdown = async (mixdown) => { + // Implementation for downloading mixdown + console.log('Download mixdown:', mixdown); + }; + + const handleCreateMix = async () => { + // Implementation for creating custom mix + console.log('Create custom mix'); + }; + + // Determine content based on media type + const renderContent = () => { + // Backing Track + if (mediaSummary.backingTrackOpen && backingTracks.length > 0) { + const backingTrack = backingTracks[0]; + return ( +
+
+

Audio File: {getFileName(backingTrack)}

+
+ +
+ setLoopEnabled(e.target.checked)} + /> + +
+ +
+ +
+
+ ); + } + + // JamTrack + if (mediaSummary.jamTrackOpen && jamTrackState.jamTrack) { + const jamTrack = jamTrackState.jamTrack; + const selectedMixdown = jamTrack.activeMixdown; + + return ( +
+
+

JamTrack: {jamTrack.name}

+

+ {selectedMixdown ? 'Custom Mix' : 'Full JamTrack'} + {downloadingJamTrack && ' (Loading...)'} +

+ {selectedMixdown &&
{selectedMixdown.name}
} +
+ + {/* My Mixes Section */} +
+

+ My Mixes {showMyMixes ? '▼' : '▶'} +

+ {showMyMixes && ( +
+ {/* Full Track Option */} +
+
Full JamTrack
+
+ +
+
+ + {/* Custom Mixes */} + {jamTrack.mixdowns && jamTrack.mixdowns.map(mixdown => ( +
+
+ {editingMixdownId === mixdown.id ? ( + { + if (e.key === 'Enter') handleMixdownSave(mixdown); + if (e.key === 'Escape') setEditingMixdownId(null); + }} + /> + ) : ( + mixdown.name + )} +
+
+ + + + +
+
+ ))} +
+ )} +
+ + {/* Create Custom Mix Section */} +
+

+ Create Custom Mix {showCustomMixes ? '▼' : '▶'} +

+ {showCustomMixes && ( +
+

Use the JamTrack controls on the session screen to set levels, mute/unmute, or pan any of the parts of the JamTrack as you like.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ )} +
+ +
+ + +
+
+ ); + } + + // Metronome + if (mediaSummary.metronomeOpen) { + return ( +
+
+

Metronome

+
+ +
+ +
+ +
+ +
+
+ ); + } + + // Recording + if (mediaSummary.recordingOpen) { + return ( +
+
+

Recording

+
+ +
+ +
+
+ ); + } + + return ( +
+
+

Media Controls

+
+

No media currently open

+
+ +
+
+ ); + }; return ( -
JKPopupMediaControls
- ) -} +
+ {renderContent()} +
+ ); +}; -export default JKPopupMediaControls \ No newline at end of file +export default JKPopupMediaControls; diff --git a/jam-ui/src/context/MediaContext.js b/jam-ui/src/context/MediaContext.js new file mode 100644 index 000000000..338643922 --- /dev/null +++ b/jam-ui/src/context/MediaContext.js @@ -0,0 +1,255 @@ +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/layouts/JKClientLayout.js b/jam-ui/src/layouts/JKClientLayout.js index d5477e62b..4b06ef55e 100644 --- a/jam-ui/src/layouts/JKClientLayout.js +++ b/jam-ui/src/layouts/JKClientLayout.js @@ -12,6 +12,7 @@ import { CurrentSessionProvider } from '../context/CurrentSessionContext'; import { MixersProvider } from '../context/MixersContext'; import { VuProvider } from '../context/VuContext'; import { GlobalProvider } from '../context/GlobalContext'; +import { MediaProvider } from '../context/MediaContext'; const JKClientLayout = ({ location }) => { @@ -28,7 +29,9 @@ const JKClientLayout = ({ location }) => { - + + + diff --git a/web/app/assets/javascripts/modern/scripts.js b/web/app/assets/javascripts/modern/scripts.js index 617ba8b3f..78120ec25 100644 --- a/web/app/assets/javascripts/modern/scripts.js +++ b/web/app/assets/javascripts/modern/scripts.js @@ -16,7 +16,7 @@ //= require utils //= require subscription_utils //= require jamkazam -//= require JamServer_copy +//= require modern/JamServer_copy //= require fakeJamClient //= require fakeJamClientMessages