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>
    )
}