Spaces:
Running
Running
| '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> | |
| ) | |
| } | |