Spaces:
Running
Running
File size: 22,252 Bytes
163eb99 453beea acf13fe 163eb99 453beea 163eb99 d7409d2 163eb99 c71919c f68b107 c71919c f68b107 163eb99 304a9b0 163eb99 f68b107 163eb99 f68b107 163eb99 f68b107 163eb99 f68b107 163eb99 453beea 163eb99 453beea 163eb99 453beea 163eb99 453beea 163eb99 453beea acf13fe 453beea 163eb99 d7409d2 163eb99 acf13fe 163eb99 acf13fe 163eb99 acf13fe d7409d2 acf13fe d7409d2 163eb99 acf13fe d7409d2 acf13fe d7409d2 acf13fe 163eb99 d7409d2 163eb99 d7409d2 163eb99 d7409d2 acf13fe 163eb99 d7409d2 acf13fe d7409d2 acf13fe d7409d2 acf13fe 163eb99 d7409d2 163eb99 d7409d2 163eb99 d7409d2 453beea acf13fe 163eb99 d7409d2 163eb99 d7409d2 163eb99 d7409d2 acf13fe d7409d2 acf13fe d7409d2 acf13fe 163eb99 acf13fe d7409d2 acf13fe d7409d2 acf13fe d7409d2 acf13fe 163eb99 acf13fe d7409d2 acf13fe d7409d2 acf13fe d7409d2 acf13fe d7409d2 acf13fe d7409d2 453beea d7409d2 453beea d7409d2 453beea d7409d2 453beea d7409d2 453beea d7409d2 453beea acf13fe 453beea d7409d2 453beea d7409d2 acf13fe 453beea acf13fe 453beea acf13fe 163eb99 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 |
'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>
)
}
|