liminal-sessions / src /components /ControlPlinth.tsx
Severian's picture
Update src/components/ControlPlinth.tsx
01a52f1 verified
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