Reuben_OS / app /components /VoiceApp.tsx
Reubencf's picture
Improve VoiceApp responsiveness and stop audio on close
d7409d2
'use client'
import React, { useState, useEffect } from 'react'
import Window from './Window'
import {
MusicNote,
FileAudio,
BookOpen,
Play,
Stop,
Pause,
DownloadSimple,
ArrowClockwise,
SpinnerGap
} from '@phosphor-icons/react'
interface VoiceAppProps {
onClose: () => void
onMinimize?: () => void
onMaximize?: () => void
onFocus?: () => void
zIndex?: number
}
interface VoiceContent {
id: string
type: 'song' | 'story'
title: string
style?: string
lyrics?: string
storyContent?: string
audioUrl?: string
timestamp: number
isProcessing: boolean
}
export function VoiceApp({ onClose, onMinimize, onMaximize, onFocus, zIndex }: VoiceAppProps) {
const [voiceContents, setVoiceContents] = useState<VoiceContent[]>([])
const [currentlyPlaying, setCurrentlyPlaying] = useState<string | null>(null)
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioElement) {
audioElement.pause()
audioElement.currentTime = 0
}
}
}, [audioElement])
// Handle close with audio cleanup
const handleClose = () => {
if (audioElement) {
audioElement.pause()
audioElement.currentTime = 0
setAudioElement(null)
setCurrentlyPlaying(null)
setIsPlaying(false)
setCurrentTime(0)
}
onClose()
}
// Load saved content from server and localStorage
useEffect(() => {
// Clear any existing problematic localStorage data on first load
try {
const saved = localStorage.getItem('voice-app-contents')
if (saved) {
const parsed = JSON.parse(saved)
// If the data contains audio URLs, clear it (this is old format)
if (parsed.some((item: VoiceContent) => item.audioUrl && item.audioUrl.length > 1000)) {
console.log('Clearing old localStorage data with embedded audio URLs')
localStorage.removeItem('voice-app-contents')
} else {
// Load localStorage content immediately for instant display
setVoiceContents(parsed)
}
}
} catch (error) {
console.warn('Error checking localStorage, clearing it:', error)
localStorage.removeItem('voice-app-contents')
}
// Load fresh content from server (will update the UI when ready)
loadContent()
// Poll for updates
const pollInterval = setInterval(() => {
loadContent()
}, 5000)
return () => clearInterval(pollInterval)
}, [])
const loadContent = async () => {
try {
// Load all content from server (no passkey required)
const response = await fetch(`/api/voice/save`)
if (response.ok) {
const data = await response.json()
if (data.success && data.content) {
// Only update if we have valid content from server
setVoiceContents(data.content)
// Also update localStorage with the latest data (without audio URLs)
try {
const contentsForStorage = data.content.map((content: VoiceContent) => ({
...content,
audioUrl: undefined // Remove audio URL to save space
}))
localStorage.setItem('voice-app-contents', JSON.stringify(contentsForStorage))
} catch (storageError) {
console.warn('Failed to update localStorage:', storageError)
}
}
}
// Don't fallback to localStorage - we already loaded it on mount
// This prevents overwriting with stale data
} catch (error) {
console.error('Failed to load voice contents from server:', error)
// Keep existing content on error
}
}
// Removed duplicate localStorage saving - now handled in loadContent
const checkForNewContent = async () => {
await loadContent()
}
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const handlePlay = (content: VoiceContent) => {
if (!content.audioUrl) return
if (currentlyPlaying === content.id && audioElement) {
if (isPlaying) {
audioElement.pause()
setIsPlaying(false)
} else {
audioElement.play()
setIsPlaying(true)
}
return
}
// Stop previous
if (audioElement) {
audioElement.pause()
audioElement.currentTime = 0
}
const audio = new Audio(content.audioUrl)
audio.addEventListener('loadedmetadata', () => {
setDuration(audio.duration)
})
audio.addEventListener('timeupdate', () => {
setCurrentTime(audio.currentTime)
})
audio.addEventListener('ended', () => {
setIsPlaying(false)
setCurrentTime(0)
})
audio.play()
setAudioElement(audio)
setCurrentlyPlaying(content.id)
setIsPlaying(true)
}
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value)
setCurrentTime(time)
if (audioElement) {
audioElement.currentTime = time
}
}
const handleStop = () => {
if (audioElement) {
audioElement.pause()
audioElement.currentTime = 0
setAudioElement(null)
setCurrentlyPlaying(null)
setIsPlaying(false)
setCurrentTime(0)
}
}
const handleDownload = async (content: VoiceContent) => {
if (!content.audioUrl) return
try {
const response = await fetch(content.audioUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${content.title.replace(/\s+/g, '_')}.mp3`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('Download failed:', error)
}
}
const handleRefresh = () => {
checkForNewContent()
}
return (
<Window
id="voice-app"
title="Voice Studio"
isOpen={true}
onClose={handleClose}
onMinimize={onMinimize}
onMaximize={onMaximize}
onFocus={onFocus}
width={850}
height={600}
x={150}
y={150}
className="voice-app-window"
headerClassName="bg-[#F5F5F7]/80 backdrop-blur-xl border-b border-gray-200/50"
zIndex={zIndex}
>
<div className="flex flex-col h-full bg-[#F5F5F7]">
{/* macOS Toolbar */}
<div className="px-2 sm:px-4 py-2 sm:py-3 bg-white/50 backdrop-blur-md border-b border-gray-200/50 flex items-center justify-between sticky top-0 z-10">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center shadow-sm flex-shrink-0">
<MusicNote size={16} weight="fill" className="text-white sm:hidden" />
<MusicNote size={18} weight="fill" className="text-white hidden sm:block" />
</div>
<div className="min-w-0">
<h2 className="text-xs sm:text-sm font-semibold text-gray-900 leading-none truncate">Voice Studio</h2>
<p className="text-[10px] sm:text-[11px] text-gray-500 mt-0.5 hidden xs:block">AI Audio Generation</p>
</div>
</div>
<button
onClick={handleRefresh}
className="p-1.5 sm:px-3 sm:py-1.5 bg-white hover:bg-gray-50 active:bg-gray-100 text-gray-700 rounded-md text-xs font-medium border border-gray-200 shadow-sm transition-all flex items-center gap-1.5 flex-shrink-0"
>
<ArrowClockwise size={14} />
<span className="hidden sm:inline">Refresh</span>
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-3 sm:p-5">
{voiceContents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center py-6 sm:py-10 px-2">
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-br from-purple-100 to-pink-100 flex items-center justify-center mb-4 sm:mb-6 shadow-inner">
<FileAudio size={32} weight="duotone" className="text-purple-500/80 sm:hidden" />
<FileAudio size={40} weight="duotone" className="text-purple-500/80 hidden sm:block" />
</div>
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-2">No Audio Content</h3>
<p className="text-xs sm:text-sm text-gray-500 max-w-sm mb-4 sm:mb-6 leading-relaxed px-2">
Ask Claude to generate song lyrics or write a story, and your audio will appear here automatically.
</p>
<div className="bg-white/60 backdrop-blur-sm rounded-xl p-3 sm:p-4 max-w-sm text-left border border-gray-200/50 shadow-sm w-full mx-2">
<p className="text-[10px] sm:text-xs font-semibold text-gray-500 mb-2 uppercase tracking-wide">Try asking Claude:</p>
<ul className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm text-gray-700">
<li className="flex items-start gap-2">
<span className="text-purple-500"></span>
<span>"Generate a pop song about coding"</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-500"></span>
<span>"Write a bedtime story and narrate it"</span>
</li>
</ul>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:gap-3">
{voiceContents.map((content) => (
<div
key={content.id}
className="bg-white rounded-lg sm:rounded-xl p-3 sm:p-4 shadow-sm border border-gray-200/60 hover:shadow-md transition-all duration-200 group"
>
<div className="flex items-start justify-between mb-2 sm:mb-3 gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${content.type === 'song'
? 'bg-purple-100 text-purple-600'
: 'bg-blue-100 text-blue-600'
}`}>
{content.type === 'song' ? (
<>
<MusicNote size={16} weight="fill" className="sm:hidden" />
<MusicNote size={20} weight="fill" className="hidden sm:block" />
</>
) : (
<>
<BookOpen size={16} weight="fill" className="sm:hidden" />
<BookOpen size={20} weight="fill" className="hidden sm:block" />
</>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-900 text-xs sm:text-sm truncate">{content.title}</h3>
<div className="flex items-center gap-1 sm:gap-2 text-[10px] sm:text-xs text-gray-500 flex-wrap">
<span className="capitalize">{content.type}</span>
<span className="hidden xs:inline"></span>
<span className="hidden xs:inline">{new Date(content.timestamp).toLocaleDateString()}</span>
{content.style && (
<>
<span className="hidden sm:inline"></span>
<span className="truncate max-w-[80px] sm:max-w-[150px] hidden sm:inline">{content.style}</span>
</>
)}
</div>
</div>
</div>
{content.audioUrl && (
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
onClick={() => handleDownload(content)}
className="p-1 sm:p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
title="Download"
>
<DownloadSimple size={16} className="sm:hidden" />
<DownloadSimple size={18} className="hidden sm:block" />
</button>
</div>
)}
</div>
{content.isProcessing ? (
<div className="flex items-center justify-center py-3 sm:py-4 bg-gray-50/50 rounded-lg border border-dashed border-gray-200">
<SpinnerGap size={18} className="text-purple-500 animate-spin sm:hidden" />
<SpinnerGap size={20} className="text-purple-500 animate-spin hidden sm:block" />
<span className="ml-2 text-xs sm:text-sm text-gray-500">Generating audio...</span>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{(content.lyrics || content.storyContent) && (
<div className="bg-gray-50/80 rounded-lg p-2 sm:p-3 max-h-20 sm:max-h-24 overflow-y-auto text-[10px] sm:text-xs text-gray-600 leading-relaxed border border-gray-100">
<p className="whitespace-pre-line">{content.lyrics || content.storyContent}</p>
</div>
)}
{content.audioUrl && (
<div className="mt-2 sm:mt-3">
{currentlyPlaying === content.id ? (
<div className="bg-white rounded-lg border border-gray-200 p-2 sm:p-3 space-y-2">
<div className="flex items-center gap-2 sm:gap-3">
<button
onClick={() => handlePlay(content)}
className="w-7 h-7 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gray-900 text-white hover:bg-gray-800 transition-colors flex-shrink-0"
>
{isPlaying ? (
<Pause size={12} weight="fill" className="sm:hidden" />
) : (
<Play size={12} weight="fill" className="sm:hidden" />
)}
{isPlaying ? (
<Pause size={14} weight="fill" className="hidden sm:block" />
) : (
<Play size={14} weight="fill" className="hidden sm:block" />
)}
</button>
<div className="flex-1 min-w-0">
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeek}
className="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-gray-900 [&::-webkit-slider-thumb]:rounded-full"
/>
<div className="flex justify-between text-[9px] sm:text-[10px] text-gray-500 mt-0.5 sm:mt-1 font-medium">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
</div>
) : (
<button
onClick={() => handlePlay(content)}
className="w-full flex items-center justify-center gap-1.5 sm:gap-2 py-2 sm:py-2.5 rounded-lg font-medium text-xs sm:text-sm bg-[#F5F5F7] text-gray-700 border border-gray-200 hover:bg-gray-200 hover:border-gray-300 transition-all active:scale-[0.98]"
>
<Play size={14} weight="fill" className="sm:hidden" />
<Play size={16} weight="fill" className="hidden sm:block" />
Play Audio
</button>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</Window>
)
}