Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react' | |
| import { useNexusStore } from '../store/nexusStore' | |
| import type { Track } from '../types/audio' | |
| interface AudioControls { | |
| loadTrack: (track: Track) => Promise<void> | |
| play: () => Promise<void> | |
| pause: () => void | |
| seekTo: (time: number) => void | |
| setVolume: (volume: number) => void | |
| getAudioElement?: () => HTMLAudioElement | null | |
| getCurrentTime?: () => number | |
| testAudioPlayback?: (url: string) => Promise<boolean> | |
| } | |
| interface ControlPlinthProps { | |
| tracks: Track[] | |
| audioControls: AudioControls | |
| } | |
| const ControlPlinth: React.FC<ControlPlinthProps> = ({ tracks, audioControls }) => { | |
| const [isTestingAudio, setIsTestingAudio] = useState(false) | |
| const [isDockCollapsed, setIsDockCollapsed] = useState(false) | |
| const timelineRef = useRef<HTMLDivElement>(null) | |
| const { | |
| playback, | |
| autoPlayEnabled, | |
| setIsPlaying, | |
| setCurrentTrack, | |
| setVolume, | |
| setCurrentTime, | |
| setAutoPlayEnabled, | |
| getNextTrack, | |
| getPreviousTrack | |
| } = useNexusStore() | |
| // Load initial track (auto-start is now handled by LivingNexusApp) | |
| useEffect(() => { | |
| const loadInitialTrack = async () => { | |
| if (tracks.length > 0 && !playback.currentTrack) { | |
| try { | |
| console.log('Loading initial track for controls:', tracks[0].title) | |
| // Test basic audio playback first | |
| if (audioControls.testAudioPlayback) { | |
| setIsTestingAudio(true) | |
| console.log('🧪 Running audio test...') | |
| const canPlay = await audioControls.testAudioPlayback(tracks[0].url) | |
| setIsTestingAudio(false) | |
| if (!canPlay) { | |
| console.error('❌ Basic audio test failed - audio may not work') | |
| return | |
| } | |
| console.log('✅ Basic audio test passed') | |
| } | |
| setCurrentTrack(tracks[0]) | |
| await audioControls.loadTrack(tracks[0]) | |
| console.log('Initial track loaded successfully (auto-start handled by main app)') | |
| } catch (error) { | |
| console.error('Failed to load initial track:', error) | |
| setIsTestingAudio(false) | |
| } | |
| } | |
| } | |
| loadInitialTrack() | |
| }, [tracks, playback.currentTrack, setCurrentTrack, audioControls, setIsTestingAudio]) | |
| const handlePlayPause = async () => { | |
| if (!playback.currentTrack) { | |
| console.log('No track selected, cannot play') | |
| return | |
| } | |
| if (isTestingAudio) { | |
| console.log('Audio test in progress, please wait...') | |
| return | |
| } | |
| try { | |
| if (playback.isPlaying) { | |
| audioControls.pause() | |
| setIsPlaying(false) | |
| } else { | |
| // Ensure track is loaded before playing | |
| console.log('Starting playback for:', playback.currentTrack.title) | |
| await audioControls.play() | |
| setIsPlaying(true) | |
| } | |
| } catch (error) { | |
| console.error('Playback control failed:', error) | |
| setIsPlaying(false) | |
| } | |
| } | |
| const handleTrackSelect = async (track: Track) => { | |
| console.log('Selecting track:', track.title) | |
| try { | |
| // Pause current track if playing | |
| if (playback.isPlaying) { | |
| audioControls.pause() | |
| setIsPlaying(false) | |
| } | |
| // Update UI immediately | |
| setCurrentTrack(track) | |
| // Load the new track | |
| console.log('Loading track:', track.title) | |
| await audioControls.loadTrack(track) | |
| console.log('Track loaded, starting playback') | |
| // Start playback | |
| await audioControls.play() | |
| setIsPlaying(true) | |
| } catch (error) { | |
| console.error('Track selection failed:', error) | |
| setIsPlaying(false) | |
| } | |
| } | |
| const handlePrevious = async () => { | |
| const previousTrack = getPreviousTrack() | |
| if (previousTrack) { | |
| await handleTrackSelect(previousTrack) | |
| } | |
| } | |
| const handleNext = async () => { | |
| const nextTrack = getNextTrack() | |
| if (nextTrack) { | |
| await handleTrackSelect(nextTrack) | |
| } | |
| } | |
| const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { | |
| if (!timelineRef.current || !playback.currentTrack) return | |
| const rect = timelineRef.current.getBoundingClientRect() | |
| const clickX = e.clientX - rect.left | |
| const percentage = clickX / rect.width | |
| // Get actual duration from audio controls | |
| const audioElement = audioControls.getAudioElement?.() | |
| const duration = audioElement?.duration || 0 | |
| if (duration > 0) { | |
| const newTime = percentage * duration | |
| audioControls.seekTo(newTime) | |
| setCurrentTime(newTime) | |
| } | |
| } | |
| const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const volume = parseFloat(e.target.value) | |
| setVolume(volume) | |
| audioControls.setVolume(volume) | |
| } | |
| const formatTime = (seconds: number): string => { | |
| const mins = Math.floor(seconds / 60) | |
| const secs = Math.floor(seconds % 60) | |
| return `${mins}:${secs.toString().padStart(2, '0')}` | |
| } | |
| const getPlayPauseGlyph = () => { | |
| return playback.isPlaying ? '‖' : '▷' | |
| } | |
| const getCurrentProgress = (): number => { | |
| // Get actual duration and current time | |
| const audioElement = audioControls.getAudioElement?.() | |
| const duration = audioElement?.duration || 0 | |
| const currentTime = audioControls.getCurrentTime?.() || playback.currentTime | |
| return duration > 0 ? (currentTime / duration) * 100 : 0 | |
| } | |
| const getCurrentDuration = (): number => { | |
| const audioElement = audioControls.getAudioElement?.() | |
| return audioElement?.duration || 0 | |
| } | |
| return ( | |
| <div | |
| className="control-plinth-dock" | |
| style={{ | |
| position: 'fixed', | |
| top: 0, | |
| left: 0, | |
| bottom: 0, | |
| width: isDockCollapsed ? '50px' : '380px', | |
| background: 'rgba(0, 0, 0, 0.95)', | |
| border: '1px solid rgba(255, 215, 0, 0.3)', | |
| borderLeft: 'none', | |
| borderTopRightRadius: '8px', | |
| borderBottomRightRadius: '8px', | |
| backdropFilter: 'blur(15px)', | |
| transition: 'all 0.3s ease-in-out', | |
| zIndex: 1000, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| overflow: 'visible', | |
| pointerEvents: 'auto' | |
| }} | |
| onMouseEnter={() => { | |
| // Disable camera controls when mouse enters audio dock | |
| const event = new CustomEvent('disableCameraControls', { detail: true }) | |
| window.dispatchEvent(event) | |
| }} | |
| onMouseLeave={() => { | |
| // Re-enable camera controls when mouse leaves audio dock | |
| const event = new CustomEvent('disableCameraControls', { detail: false }) | |
| window.dispatchEvent(event) | |
| }} | |
| > | |
| {/* Dock Toggle Tab - Prominent and Always Visible */} | |
| <div | |
| style={{ | |
| position: 'absolute', | |
| right: '-45px', | |
| top: '50%', | |
| transform: 'translateY(-50%)', | |
| zIndex: 1003 | |
| }} | |
| > | |
| <button | |
| onClick={() => setIsDockCollapsed(!isDockCollapsed)} | |
| onMouseEnter={(e) => { | |
| e.currentTarget.style.transform = 'scale(1.1)' | |
| e.currentTarget.style.backgroundColor = 'rgba(255, 215, 0, 0.4)' | |
| e.currentTarget.style.boxShadow = '0 0 20px rgba(255, 215, 0, 0.8)' | |
| }} | |
| onMouseLeave={(e) => { | |
| e.currentTarget.style.transform = 'scale(1)' | |
| e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.95)' | |
| e.currentTarget.style.boxShadow = '2px 0 15px rgba(0, 0, 0, 0.7)' | |
| }} | |
| style={{ | |
| background: 'rgba(0, 0, 0, 0.95)', | |
| border: '3px solid rgba(255, 215, 0, 0.6)', | |
| borderRadius: '0 15px 15px 0', | |
| borderLeft: 'none', | |
| width: '45px', | |
| height: '90px', | |
| color: '#FFD700', | |
| cursor: 'pointer', | |
| fontSize: '22px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| backdropFilter: 'blur(15px)', | |
| transition: 'all 0.3s ease-in-out', | |
| boxShadow: '2px 0 15px rgba(0, 0, 0, 0.7)', | |
| fontWeight: 'bold', | |
| outline: 'none' | |
| }} | |
| title={isDockCollapsed ? 'Expand Audio Player' : 'Collapse Audio Player'} | |
| > | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '2px' | |
| }}> | |
| <div style={{ fontSize: '18px' }}>{isDockCollapsed ? '▶' : '◀'}</div> | |
| <div style={{ | |
| fontSize: '8px', | |
| opacity: 0.8, | |
| writingMode: 'vertical-rl', | |
| textOrientation: 'mixed', | |
| letterSpacing: '1px' | |
| }}> | |
| {isDockCollapsed ? 'EXPAND' : 'HIDE'} | |
| </div> | |
| </div> | |
| </button> | |
| </div> | |
| {/* Collapsed State - Enhanced with Progress */} | |
| {isDockCollapsed && ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| padding: '20px 10px', | |
| gap: '15px', | |
| height: '100%', | |
| justifyContent: 'center', | |
| position: 'relative' | |
| }}> | |
| {/* Vertical Progress Indicator */} | |
| <div style={{ | |
| position: 'absolute', | |
| left: '5px', | |
| top: '20px', | |
| bottom: '20px', | |
| width: '3px', | |
| background: 'rgba(255, 215, 0, 0.3)', | |
| borderRadius: '2px' | |
| }}> | |
| <div style={{ | |
| position: 'absolute', | |
| bottom: 0, | |
| width: '100%', | |
| height: `${getCurrentProgress()}%`, | |
| background: 'linear-gradient(to top, #FFD700, #FF6B35)', | |
| borderRadius: '2px', | |
| transition: 'height 0.1s ease' | |
| }} /> | |
| </div> | |
| <button | |
| className="central-glyph" | |
| onClick={handlePlayPause} | |
| disabled={!playback.currentTrack} | |
| style={{ | |
| width: '40px', | |
| height: '40px', | |
| fontSize: '16px', | |
| animation: playback.isPlaying ? 'glyphPulse 2s ease-in-out infinite' : 'none' | |
| }} | |
| > | |
| {getPlayPauseGlyph()} | |
| </button> | |
| {/* Volume Indicator */} | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '5px' | |
| }}> | |
| <div style={{ | |
| fontSize: '8px', | |
| color: '#8B6914' | |
| }}> | |
| 🔊 | |
| </div> | |
| <div style={{ | |
| width: '2px', | |
| height: '30px', | |
| background: 'rgba(255, 215, 0, 0.3)', | |
| borderRadius: '1px', | |
| position: 'relative' | |
| }}> | |
| <div style={{ | |
| position: 'absolute', | |
| bottom: 0, | |
| width: '100%', | |
| height: `${playback.volume * 100}%`, | |
| background: '#FFD700', | |
| borderRadius: '1px', | |
| transition: 'height 0.2s ease' | |
| }} /> | |
| </div> | |
| </div> | |
| <div style={{ | |
| writingMode: 'vertical-rl', | |
| textOrientation: 'mixed', | |
| fontSize: '9px', | |
| color: '#FFD700', | |
| textAlign: 'center', | |
| maxHeight: '150px', | |
| overflow: 'hidden', | |
| lineHeight: '1.2' | |
| }}> | |
| {playback.currentTrack?.title || 'No Track'} | |
| </div> | |
| {/* Track Position Indicator with Auto-play Status */} | |
| {playback.currentTrack && ( | |
| <div style={{ | |
| fontSize: '8px', | |
| color: '#8B6914', | |
| textAlign: 'center', | |
| transform: 'rotate(-90deg)', | |
| whiteSpace: 'nowrap', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '4px' | |
| }}> | |
| <div>{tracks.findIndex(t => t.id === playback.currentTrack?.id) + 1}/{tracks.length}</div> | |
| {autoPlayEnabled && ( | |
| <div style={{ | |
| fontSize: '6px', | |
| color: '#FFD700', | |
| opacity: 0.8 | |
| }}> | |
| AUTO | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Expanded State - Full Controls */} | |
| {!isDockCollapsed && ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| height: '100%', | |
| padding: '20px', | |
| gap: '15px' | |
| }}> | |
| {/* Track Information Display */} | |
| <div className="track-info" style={{ | |
| textAlign: 'center', | |
| borderBottom: '1px solid rgba(255, 215, 0, 0.2)', | |
| paddingBottom: '15px' | |
| }}> | |
| <div className="track-title" style={{ | |
| fontSize: '16px', | |
| fontWeight: 600, | |
| color: '#FFD700', | |
| marginBottom: '4px' | |
| }}> | |
| {playback.currentTrack?.title || 'Select Track'} | |
| </div> | |
| <div className="track-artist" style={{ | |
| fontSize: '12px', | |
| color: '#8B6914' | |
| }}> | |
| {playback.currentTrack?.artist || ''} | |
| </div> | |
| </div> | |
| {/* Main Controls */} | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '15px' | |
| }}> | |
| {/* Transport Controls */} | |
| <div style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '10px' | |
| }}> | |
| <button | |
| className="plinth-control" | |
| onClick={handlePrevious} | |
| disabled={!playback.currentTrack} | |
| style={{ | |
| padding: '8px', | |
| fontSize: '14px' | |
| }} | |
| > | |
| ◁ | |
| </button> | |
| <button | |
| className="central-glyph" | |
| onClick={handlePlayPause} | |
| disabled={!playback.currentTrack} | |
| style={{ | |
| width: '50px', | |
| height: '50px', | |
| fontSize: '20px' | |
| }} | |
| > | |
| {getPlayPauseGlyph()} | |
| </button> | |
| <button | |
| className="plinth-control" | |
| onClick={handleNext} | |
| disabled={!playback.currentTrack} | |
| style={{ | |
| padding: '8px', | |
| fontSize: '14px' | |
| }} | |
| > | |
| ▷ | |
| </button> | |
| </div> | |
| {/* Timeline Scrubber */} | |
| <div style={{ width: '100%' }}> | |
| <div | |
| ref={timelineRef} | |
| className="timeline-channel" | |
| onClick={handleTimelineClick} | |
| style={{ | |
| width: '100%', | |
| height: '8px', | |
| margin: '0' | |
| }} | |
| > | |
| <div | |
| className="timeline-indicator" | |
| style={{ width: `${getCurrentProgress()}%` }} | |
| /> | |
| </div> | |
| {/* Time Display */} | |
| <div className="time-display" style={{ | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| gap: '8px', | |
| fontSize: '10px', | |
| color: '#8B6914', | |
| marginTop: '5px' | |
| }}> | |
| <span>{formatTime(audioControls.getCurrentTime?.() || playback.currentTime)}</span> | |
| <span>{formatTime(getCurrentDuration())}</span> | |
| </div> | |
| </div> | |
| {/* Volume Control */} | |
| <div className="volume-control" style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '8px', | |
| width: '100%' | |
| }}> | |
| <label style={{ fontSize: '12px', color: '#FFD700' }}>Volume</label> | |
| <input | |
| type="range" | |
| min="0" | |
| max="1" | |
| step="0.05" | |
| value={playback.volume} | |
| onChange={handleVolumeChange} | |
| className="volume-slider" | |
| style={{ width: '100%' }} | |
| /> | |
| </div> | |
| {/* Auto-play Control */} | |
| <div className="autoplay-control" style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '8px', | |
| width: '100%', | |
| padding: '8px', | |
| background: 'rgba(255, 215, 0, 0.1)', | |
| border: '1px solid rgba(255, 215, 0, 0.3)', | |
| borderRadius: '6px' | |
| }}> | |
| <label style={{ | |
| fontSize: '12px', | |
| color: '#FFD700', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '6px' | |
| }}> | |
| <input | |
| type="checkbox" | |
| checked={autoPlayEnabled} | |
| onChange={(e) => setAutoPlayEnabled(e.target.checked)} | |
| style={{ | |
| transform: 'scale(1.2)', | |
| accentColor: '#FFD700' | |
| }} | |
| /> | |
| Auto-play Album | |
| </label> | |
| </div> | |
| </div> | |
| {/* Track List - Extended Vertically */} | |
| <div className="track-list-tablet" style={{ | |
| flex: 1, | |
| background: 'rgba(0, 0, 0, 0.5)', | |
| border: '1px solid rgba(255, 215, 0, 0.2)', | |
| borderRadius: '6px', | |
| padding: '15px', | |
| marginTop: '15px', | |
| overflow: 'hidden', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| minHeight: '300px', // Ensure minimum height for better track visibility | |
| maxHeight: 'calc(100vh - 280px)' // Allow it to grow but leave space for controls | |
| }}> | |
| <div className="tablet-header" style={{ | |
| textAlign: 'center', | |
| marginBottom: '10px', | |
| paddingBottom: '8px', | |
| borderBottom: '1px solid rgba(255, 215, 0, 0.3)' | |
| }}> | |
| <h3 style={{ | |
| margin: 0, | |
| color: '#FFD700', | |
| fontSize: '12px' | |
| }}>Album Tracks</h3> | |
| </div> | |
| <div className="track-list" style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '8px', | |
| overflowY: 'auto', | |
| overflowX: 'hidden', | |
| flex: 1, | |
| paddingRight: '8px' // Add padding for scrollbar | |
| }}> | |
| {tracks.map((track, index) => ( | |
| <div | |
| key={track.id} | |
| className={`track-item ${ | |
| playback.currentTrack?.id === track.id ? 'active' : '' | |
| }`} | |
| onClick={() => handleTrackSelect(track)} | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '8px', | |
| padding: '10px 8px', | |
| borderRadius: '6px', | |
| cursor: 'pointer', | |
| transition: 'all 0.2s ease', | |
| border: '1px solid transparent', | |
| background: playback.currentTrack?.id === track.id | |
| ? 'linear-gradient(45deg, rgba(255, 215, 0, 0.2), rgba(139, 105, 20, 0.3))' | |
| : 'transparent', | |
| borderColor: playback.currentTrack?.id === track.id ? 'rgba(255, 215, 0, 0.4)' : 'transparent' | |
| }} | |
| onMouseEnter={(e) => { | |
| if (playback.currentTrack?.id !== track.id) { | |
| e.currentTarget.style.background = 'rgba(255, 215, 0, 0.1)' | |
| e.currentTarget.style.borderColor = 'rgba(255, 215, 0, 0.2)' | |
| } | |
| }} | |
| onMouseLeave={(e) => { | |
| if (playback.currentTrack?.id !== track.id) { | |
| e.currentTarget.style.background = 'transparent' | |
| e.currentTarget.style.borderColor = 'transparent' | |
| } | |
| }} | |
| > | |
| <span className="track-number" style={{ | |
| fontSize: '10px', | |
| color: playback.currentTrack?.id === track.id ? '#FFD700' : '#8B6914', | |
| minWidth: '20px', | |
| fontWeight: playback.currentTrack?.id === track.id ? 'bold' : 'normal' | |
| }}> | |
| {playback.currentTrack?.id === track.id && playback.isPlaying ? '◈' : (index + 1)} | |
| </span> | |
| <div className="track-details" style={{ | |
| flex: 1, | |
| overflow: 'hidden', | |
| paddingRight: '5px' | |
| }}> | |
| <div className="track-name" style={{ | |
| fontSize: '12px', | |
| color: playback.currentTrack?.id === track.id ? '#FFD700' : '#F5E6D3', | |
| marginBottom: '2px', | |
| whiteSpace: 'nowrap', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| fontWeight: playback.currentTrack?.id === track.id ? 'bold' : 'normal' | |
| }}>{track.title}</div> | |
| <div className="track-artist" style={{ | |
| fontSize: '9px', | |
| color: '#8B6914', | |
| opacity: 0.8 | |
| }}>{track.artist}</div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| export default ControlPlinth |