// App.tsx — Root application component // // Ported from hot-step-9000: 3-panel resizable layout with // Sidebar | CreatePanel | SongList | RightSidebar | Player. // // Playback is managed by playbackStore — App.tsx just renders WaveSurfer // DOM hosts and wires their callbacks to the store. import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from './context/AuthContext'; import { GlobalParamsProvider, useGlobalParams } from './context/GlobalParamsContext'; import { usePersistedState } from './hooks/usePersistedState'; import { useTheme } from './hooks/useTheme'; import { songApi } from './services/api'; import { Sidebar } from './components/sidebar/Sidebar'; import { CreatePanel } from './components/create/CreatePanel'; import { SongList } from './components/library/SongList'; import { enqueueSimpleGen, useResumeQueue, useAudioGenQueueSelector, clearFinishedFromAudioQueue } from './stores/audioGenQueueStore'; import { clearRecentSongsCache } from './components/shared/UnifiedRecentSongs'; import { ActivitySidebar } from './components/shared/ActivitySidebar'; import { Player } from './components/player/Player'; import { WaveformPlayer, type WaveformPlayerHandle } from './components/player/WaveformPlayer'; import { SectionMarkers } from './components/player/SectionMarkers'; import { LyricsBar } from './components/player/LyricsBar'; import { TrimControls } from './components/player/TrimControls'; import { SpectrumAnalyzer } from './components/player/SpectrumAnalyzer'; import { RightSidebar } from './components/details/RightSidebar'; import { MetadataEditorModal } from './components/details/MetadataEditorModal'; import { CoverArtPromptModal } from './components/library/CoverArtPromptModal'; import { Toast, type ToastType } from './components/shared/Toast'; import { ConfirmDialog } from './components/shared/ConfirmDialog'; import { downloadTrack } from './utils/downloadTrack'; import { SettingsPanel, type AppSettings, DEFAULT_SETTINGS } from './components/settings/SettingsPanel'; import { TerminalPanel } from './components/terminal/TerminalPanel'; import { AssistantPanel } from './components/assistant/AssistantPanel'; import { LyricStudioV2 } from './components/lyric-studio/LyricStudioV2'; import { CoverStudio } from './components/cover-studio/CoverStudio'; import { StemStudio } from './components/stem-studio/StemStudio'; import { StemBuilder } from './components/stem-builder/StemBuilder'; import { RepaintStudio } from './components/repaint-studio/RepaintStudio'; import { SongBuilder } from './components/song-builder/SongBuilder'; import { StormPage } from './components/storm/StormPage'; import { MidiStudio } from './components/midi-studio/MidiStudio'; import { TrainingStudio } from './components/training-studio/TrainingStudio'; import { GlobalParamBar } from './components/global-bar/GlobalParamBar'; import { InstaGenPanel } from './components/insta-gen/InstaGenPanel'; import { PlaylistSidebar } from './components/playlist/PlaylistSidebar'; import { usePlaybackSelector, registerPlayers, getActiveMediaElement, handleOriginalReady, handleAltReady, handleFinish as pbHandleFinish, setCurrentTime as pbSetCurrentTime, setIsPlaying as pbSetIsPlaying, togglePlay as pbTogglePlay, seek as pbSeek, next as pbNext, previous as pbPrevious, setVolume as pbSetVolume, setPlaybackRate as pbSetPlaybackRate, setShuffle as pbSetShuffle, cycleRepeat as pbCycleRepeat, setSpectrumEnabled as pbSetSpectrumEnabled, toggleMastered as pbToggleMastered, setTrimMode as pbSetTrimMode, handleTrimClick as pbHandleTrimClick, reloadCurrentTrack as pbReloadCurrentTrack, toggleAB as pbToggleAB, exitABMode as pbExitABMode, stop as pbStop, songToTrack, playFromList, } from './stores/playbackStore'; import type { Song, GenerationParams } from './types'; import { usePlaylist, addToPlaylist } from './components/lyric-studio/playlistStore'; import { DisguiseModeProvider } from './hooks/useDisguiseMode'; import { ABCompareModal } from './components/shared/ABCompareModal'; import { useABCompareSelector, playAB, openModal as openABModal, clear as clearAB } from './stores/abCompareStore'; import { useDiscoMode, toggleDiscoMode, setDiscoPlaying, setDiscoDataUrl, syncStems, updateMainTime } from './stores/discoStore'; import { DiscoPulseWrapper } from './components/shared/DiscoPulseWrapper'; import { HiHatParticles } from './components/shared/HiHatParticles'; /** Derive top-level view from the browser URL */ function viewFromUrl(path = window.location.pathname): string { if (path.startsWith('/insta-gen')) return 'insta-gen'; if (path.startsWith('/lyric-studio')) return 'lyric-studio'; if (path.startsWith('/cover-studio')) return 'cover-studio'; if (path.startsWith('/stem-studio')) return 'stem-studio'; if (path.startsWith('/stem-builder')) return 'stem-builder'; if (path.startsWith('/song-builder')) return 'song-builder'; if (path.startsWith('/storm')) return 'storm'; if (path.startsWith('/midi-studio')) return 'midi-studio'; if (path.startsWith('/training-studio')) return 'training-studio'; if (path.startsWith('/repaint')) return 'repaint'; if (path.startsWith('/library')) return 'library'; if (path.startsWith('/settings')) return 'settings'; return 'create'; } /** Map view names to URL paths */ function urlForView(view: string): string { if (view === 'insta-gen') return '/insta-gen'; if (view === 'lyric-studio') { // Restore the last deep URL (artist/album/tab) if we have one try { const saved = localStorage.getItem('hs-lastLyricStudioUrl'); if (saved) return saved; } catch { /* ignore */ } return '/lyric-studio'; } if (view === 'cover-studio') return '/cover-studio'; if (view === 'stem-studio') return '/stem-studio'; if (view === 'stem-builder') return '/stem-builder'; if (view === 'song-builder') return '/song-builder'; if (view === 'storm') return '/storm'; if (view === 'midi-studio') return '/midi-studio'; if (view === 'training-studio') return '/training-studio'; if (view === 'repaint') return '/repaint'; if (view === 'library') return '/library'; if (view === 'settings') return '/settings'; return '/'; } /** Restarting overlay — polls /api/health and reloads when the server is back */ const RestartingOverlay: React.FC = () => { const { t } = useTranslation(); const [status, setStatus] = useState(t('app.restarting.stopping')); const [dots, setDots] = useState(''); // Animate dots useEffect(() => { const iv = setInterval(() => { setDots(prev => prev.length >= 3 ? '' : prev + '.'); }, 500); return () => clearInterval(iv); }, []); // Poll for server health useEffect(() => { let cancelled = false; let attempt = 0; // Wait a moment before polling (give the server time to die) const startDelay = setTimeout(() => { setStatus(t('app.restarting.waiting')); const poll = setInterval(async () => { attempt++; try { const res = await fetch('/api/health', { signal: AbortSignal.timeout(2000) }); if (res.ok && !cancelled) { clearInterval(poll); setStatus(t('app.restarting.reconnected')); setTimeout(() => window.location.reload(), 500); } } catch { // Still down — keep polling if (attempt > 5 && !cancelled) { setStatus(t('app.restarting.waiting')); } } }, 2000); // Cleanup return () => { cancelled = true; clearInterval(poll); }; }, 3000); return () => { cancelled = true; clearTimeout(startDelay); }; }, []); return (
{/* Animated spinner */}

{t('app.restarting.title')}

{status}{dots}

{t('app.restarting.message')}

); }; /** Compact A/B comparison bar — shows above the player when both tracks are pinned */ const ABMiniBar: React.FC = () => { const trackA = useABCompareSelector(s => s.trackA); const trackB = useABCompareSelector(s => s.trackB); if (!trackA || !trackB) return null; return (
A {trackA.title} vs B {trackB.title}
); }; /** Extract the source tag from a song's generation params */ function getSongSource(song: Song): string { return (song.generationParams as any)?.source || (song.generation_params as any)?.source || 'create'; } /** Inner app content — must be rendered inside GlobalParamsProvider */ const AppContent: React.FC = () => { const { t } = useTranslation(); const { token, isLoading } = useAuth(); const [activeView, setActiveView] = useState(() => viewFromUrl()); const [songs, setSongs] = useState([]); const [selectedSong, setSelectedSong] = useState(null); // Resizable panel widths (persisted) const [createPanelWidth, setCreatePanelWidth] = usePersistedState('ace-createPanelWidth', 490); const [rightSidebarWidth, setRightSidebarWidth] = usePersistedState('ace-rightSidebarWidth', 360); const [activitySidebarWidth, setActivitySidebarWidth] = usePersistedState('hs-activitySidebarWidth', 320); const [showRightSidebar, setShowRightSidebar] = useState(true); // Terminal panel state (persisted) const [showTerminal, setShowTerminal] = usePersistedState('ace-showTerminal', false); const [terminalWidth, setTerminalWidth] = usePersistedState('ace-terminalWidth', 450); // Assistant panel state (persisted) const [showAssistant, setShowAssistant] = usePersistedState('hs-showAssistant', false); const [assistantWidth, setAssistantWidth] = usePersistedState('hs-assistantWidth', 420); // Settings state (persisted) const [settings, setSettings] = usePersistedState('ace-settings', DEFAULT_SETTINGS); // Theme state const { theme, toggleTheme } = useTheme(); // Playlist sidebar (persisted) const [showPlaylist, setShowPlaylist] = usePersistedState('ace-showPlaylist', false); const [playlistWidth, setPlaylistWidth] = usePersistedState('ace-playlistWidth', 300); const playlistData = usePlaylist(); // ── Playback (from unified store — each selector only re-renders when its value changes) ── const currentTrack = usePlaybackSelector(s => s.currentTrack); const currentSong = currentTrack as (Song | null); // PlaybackTrack is Song-compatible for rendering const isPlaying = usePlaybackSelector(s => s.isPlaying); const playerActive = usePlaybackSelector(s => s.playerActive); const currentTime = usePlaybackSelector(s => s.currentTime); const duration = usePlaybackSelector(s => s.duration); const volume = usePlaybackSelector(s => s.volume); const playbackRate = usePlaybackSelector(s => s.playbackRate); const playMastered = usePlaybackSelector(s => s.playMastered); const spectrumEnabled = usePlaybackSelector(s => s.spectrumEnabled); const shuffle = usePlaybackSelector(s => s.shuffle); const repeat = usePlaybackSelector(s => s.repeat); const trimMode = usePlaybackSelector(s => s.trimMode); const trimInPoint = usePlaybackSelector(s => s.trimInPoint); const trimOutPoint = usePlaybackSelector(s => s.trimOutPoint); const trimClickCount = usePlaybackSelector(s => s.trimClickCount); const abMode = usePlaybackSelector(s => s.abMode); const abActiveLabel = usePlaybackSelector(s => s.playMastered && s.abMode ? 'B' as const : 'A' as const); const wavesurferRef = useRef(null); const wavesurferAltRef = useRef(null); const discoMode = useDiscoMode(); // Disco hue assignments per panel (0-360) const DISCO = { sidebar: 120, // green createPanel: 330, // hot pink songGrid: 195, // deep sky blue activity: 270, // purple playlist: 20, // orange assistant: 175, // cyan terminal: 135, // green player: 45, // gold rightSidebar: 300, // magenta storm: 210, // electric blue trainingStudio: 60, // amber }; // Register WaveSurfer ref objects with playback store. // We pass the refs themselves (not .current) so the store always gets // the latest handle even if useImperativeHandle hasn't committed yet. useEffect(() => { registerPlayers(wavesurferRef, wavesurferAltRef); }, []); // Track spectrum analyzer media element — updates on mastered toggle const [spectrumMediaEl, setSpectrumMediaEl] = useState(null); useEffect(() => { const el = getActiveMediaElement(); if (el) setSpectrumMediaEl(el); }, [playMastered, currentTrack, isPlaying]); // Sync disco beat detection with playback state useEffect(() => { setDiscoPlaying(isPlaying); syncStems(isPlaying ? 'play' : 'pause', currentTime); }, [isPlaying]); // Feed current time to disco store every frame — this is the sync backbone. // Pre-analyzed stems look up energy at this time, so it's always in sync // with the main player regardless of seek, pause, rate changes. useEffect(() => { updateMainTime(currentTime); }, [currentTime]); // Load disco data JSON when track changes — trigger on-demand extraction if missing useEffect(() => { const discoUrl = currentTrack?.discoDataUrl || ''; setDiscoDataUrl(discoUrl); // If disco extraction is on and this track has no disco data, trigger extraction if (settings.discoKickExtract && currentTrack?.id && !discoUrl) { console.log(`[Disco] Track ${currentTrack.id} has no disco data — triggering extraction`); let cancelled = false; (async () => { try { const res = await fetch(`/api/songs/${currentTrack.id}/extract-kick`, { method: 'POST' }); const data = await res.json(); if (cancelled) return; if (data.status === 'exists' && data.discoDataUrl) { console.log(`[Disco] Disco data already exists for ${currentTrack.id}`); setDiscoDataUrl(data.discoDataUrl); return; } if (data.status !== 'started') return; console.log(`[Disco] Stem extraction started for ${currentTrack.id}`); // Poll until disco data URL appears on the song for (let i = 0; i < 360 && !cancelled; i++) { await new Promise(r => setTimeout(r, 5000)); if (cancelled) return; try { const songRes = await fetch(`/api/songs/${currentTrack.id}`); const songData = await songRes.json(); const s = songData.song || songData; const ddUrl = s.disco_data_url || ''; if (ddUrl) { console.log(`[Disco] Disco data ready for ${currentTrack.id}: ${ddUrl}`); setDiscoDataUrl(ddUrl); return; } } catch { /* keep polling */ } } } catch (err) { console.warn('[Disco] On-demand extraction failed:', err); } })(); return () => { cancelled = true; }; } }, [currentTrack?.id, currentTrack?.discoDataUrl, settings.discoKickExtract]); // ── Trim mode waveform click handler ── const handleWaveformClick = useCallback((timeSec: number) => { if (trimMode) { pbHandleTrimClick(timeSec); } }, [trimMode]); // Wrap seek to also sync kick stem const handleSeek = useCallback((time: number) => { pbSeek(time); syncStems('seek', time); }, []); // Toast state const [toast, setToast] = useState<{ message: string; type: ToastType; isVisible: boolean }>({ message: '', type: 'success', isVisible: false, }); // Confirm dialog state const [confirmDialog, setConfirmDialog] = useState<{ title: string; message: string; confirmLabel?: string; danger?: boolean; onConfirm: () => void; } | null>(null); const [isShutdown, setIsShutdown] = useState(false); const [isRestarting, setIsRestarting] = useState(false); // ── URL-based routing ────────────────────────────────────── const navigateTo = useCallback((view: string) => { // Save deep Lyric Studio URL before leaving so we can restore it if (activeView === 'lyric-studio' && view !== 'lyric-studio') { try { localStorage.setItem('hs-lastLyricStudioUrl', window.location.pathname); } catch { /* ignore */ } } setActiveView(view); const url = urlForView(view); if (window.location.pathname !== url) { window.history.pushState(null, '', url); } }, [activeView]); // Handle browser back/forward useEffect(() => { const handlePopState = () => setActiveView(viewFromUrl()); window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Direct download helper (reads settings from localStorage) const handleDownload = useCallback((song: Song) => downloadTrack(song), []); const showToast = (message: string, type: ToastType = 'success') => { setToast({ message, type, isVisible: true }); }; // Disco mode toggle — prompt to enable stem extraction if needed const handleToggleDisco = useCallback(() => { if (discoMode) { // Turning off — always allowed toggleDiscoMode(); return; } // Turning on — check if stem extraction is enabled if (!settings.discoKickExtract) { setConfirmDialog({ title: '🪩 Enable Disco Mode?', message: 'Disco mode uses separated drum stems (kick, snare, hi-hat) for beat-reactive visuals. This requires extracting drum stems after each generation (~60-90s extra per song). Enable stem extraction and activate disco mode?', confirmLabel: 'Enable & Activate', onConfirm: () => { setSettings(prev => ({ ...prev, discoKickExtract: true })); toggleDiscoMode(); showToast('🪩 Disco mode enabled! Drum stems will be extracted for new songs.', 'success'); }, }); } else { toggleDiscoMode(); } }, [discoMode, settings.discoKickExtract]); // Song created callback — add to library + trigger sidebar refresh const [songCreatedCount, setSongCreatedCount] = useState(0); const handleSongCreated = useCallback((song: Song) => { setSongs(prev => [song, ...prev.filter(s => s.id !== song.id)]); setSongCreatedCount(c => c + 1); // Auto-extract drum stems + disco data for disco mode if setting enabled if (settings.discoKickExtract && song.id && !song.disco_data_url) { console.log(`[Disco] Auto-extracting drum stems for song ${song.id}`); fetch(`/api/songs/${song.id}/extract-kick`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status === 'started') { console.log(`[Disco] Stem extraction + analysis started for ${song.id}`); } }) .catch(err => console.warn('[Disco] Stem extraction failed:', err)); } }, [settings.discoKickExtract]); // Play a song from the library (used by SongList, RightSidebar) const playSong = useCallback((song: Song) => { setSelectedSong(song); setShowRightSidebar(true); playFromList(songToTrack(song), songs.map(songToTrack), 'library'); }, [songs]); // Shared audio generation queue — resume on mount, subscribe only to active count useResumeQueue(token || undefined); const activeJobCount = useAudioGenQueueSelector(s => s.items.filter(i => i.status === 'pending' || i.status === 'loading-adapter' || i.status === 'generating').length ); // Track the active streaming job ID — for SSE connection in CreatePanel const streamJobId = useAudioGenQueueSelector(s => { const active = s.items.find(i => i.status === 'generating' && i.jobId && (i.globalParams as any)?.streamMode === true ); return active?.jobId || null; }); // Load songs on mount useEffect(() => { if (!token) return; songApi.list(token) .then(({ songs }) => setSongs(songs)) .catch(err => console.error('[App] Failed to load songs:', err)); }, [token]); // Refresh songs (kept for future use — e.g. after external DB changes) // const refreshSongsList = useCallback(() => { // if (!token) return; // songApi.list(token) // .then(({ songs }) => setSongs(songs)) // .catch(err => console.error('[App] Refresh failed:', err)); // }, [token]); // Global params from context const globalParams = useGlobalParams(); // Handle generation — merges content params (from CreatePanel) with global engine params (from context) const handleGenerate = useCallback((contentParams: Partial) => { if (!token) return; // Merge: global engine params + content-specific params + settings const engineParams = globalParams.getGlobalParams(); const enrichedParams = { ...engineParams, ...contentParams, source: 'create', coResident: settings.coResident, cacheLmCodes: settings.cacheLmCodes, parallelWhisper: settings.parallelWhisper, parallelQualityEval: settings.parallelQualityEval, parallelCoverArt: settings.parallelCoverArt, }; enqueueSimpleGen( enrichedParams, token, handleSongCreated, ); }, [token, settings, globalParams, handleSongCreated]); // Handle delete const handleDelete = useCallback(async (song: Song) => { if (!token) return; await songApi.delete(song.id, token); setSongs(prev => prev.filter(s => s.id !== song.id)); if (selectedSong?.id === song.id) setSelectedSong(null); showToast(`Deleted "${song.title}"`, 'success'); }, [token, selectedSong]); // Handle bulk delete const handleBulkDelete = useCallback((ids: string[]) => { if (!token || ids.length === 0) return; setConfirmDialog({ title: `Delete ${ids.length} song${ids.length !== 1 ? 's' : ''}?`, message: `This will permanently delete ${ids.length} track${ids.length !== 1 ? 's' : ''} and their audio files from disk.`, confirmLabel: `Delete ${ids.length}`, danger: true, onConfirm: async () => { setConfirmDialog(null); try { await songApi.bulkDelete(ids, token); const idSet = new Set(ids); setSongs(prev => prev.filter(s => !idSet.has(s.id))); if (selectedSong && idSet.has(selectedSong.id)) setSelectedSong(null); showToast(`Deleted ${ids.length} track${ids.length !== 1 ? 's' : ''}`, 'success'); } catch (err: any) { showToast(`Delete failed: ${err.message}`, 'error'); } }, }); }, [token, selectedSong]); // Reuse handler const [reuseData, setReuseData] = useState<{ song: Song; timestamp: number } | null>(null); const handleReuse = useCallback((song: Song) => { setReuseData({ song, timestamp: Date.now() }); navigateTo('create'); }, [navigateTo]); // Send-to-Cover handler — loads a library track into Cover Studio (#61) const [coverSource, setCoverSource] = useState<{ song: Song; timestamp: number } | null>(null); const handleSendToCover = useCallback((song: Song) => { setCoverSource({ song, timestamp: Date.now() }); navigateTo('cover-studio'); }, [navigateTo]); // Metadata editor (#60) const [metadataEditSong, setMetadataEditSong] = useState(null); const handleEditMetadata = useCallback((song: Song) => setMetadataEditSong(song), []); // Song update handler const handleSongUpdate = useCallback((updatedSong: Song) => { setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s)); if (selectedSong?.id === updatedSong.id) setSelectedSong(updatedSong); }, [selectedSong]); // Listen for cover art completion events (fired by SongList context menu) useEffect(() => { const handler = (e: Event) => { const { songId, coverUrl } = (e as CustomEvent).detail; if (!songId || !coverUrl) return; setSongs(prev => prev.map(s => s.id === songId ? { ...s, coverUrl, cover_url: coverUrl } : s )); // Also update selected song if it's the one that got cover art setSelectedSong(prev => prev?.id === songId ? { ...prev, coverUrl, cover_url: coverUrl } as Song : prev ); }; window.addEventListener('cover-art-updated', handler); return () => window.removeEventListener('cover-art-updated', handler); }, []); // Listen for song-created events (fired by audioGenQueueStore on completion) // This covers Lyric Studio, Cover Studio, and resumed queue items. useEffect(() => { const handler = (e: Event) => { const { song } = (e as CustomEvent).detail; if (!song) return; handleSongCreated(song); }; window.addEventListener('song-created', handler); return () => window.removeEventListener('song-created', handler); }, [handleSongCreated]); // Rename handler — PATCH title to server + update local state const handleRename = useCallback(async (song: Song, newTitle: string) => { try { const { song: updated } = await songApi.update(song.id, { title: newTitle }, token!); const normalized = { ...song, ...updated, title: newTitle }; handleSongUpdate(normalized); } catch (err: any) { showToast(`Rename failed: ${err.message}`, 'error'); } }, [token, handleSongUpdate]); // ── Player Logic ── // All playback state and logic lives in playbackStore. // App.tsx just wires WaveSurfer DOM callbacks to the store. // (No inline playSong/playNext/playPrevious/toggleMastered etc.) // ── Render ──────────────────────────────────────────────── if (isLoading) { return (
{t('app.loading')}
); } if (isShutdown) { return (
👋

{t('app.shutdown.title')}

{t('app.shutdown.message')}

); } if (isRestarting) { return ; } // Main content renderer const renderContent = () => { if (activeView === 'settings') { return ( { setSongs([]); setSelectedSong(null); // Queue + Recent Songs live outside library state: the queue is a // persisted singleton store, Recent Songs a module-level cache — // both survive the nuke unless explicitly cleared. clearFinishedFromAudioQueue(); clearRecentSongsCache(); }} /> ); } if (activeView === 'lyric-studio') { return ( ); } if (activeView === 'cover-studio') { return ( ); } if (activeView === 'stem-studio') { return ( ); } if (activeView === 'stem-builder') { return ( ); } if (activeView === 'storm') { return ( ); } if (activeView === 'midi-studio') { return ( ); } if (activeView === 'training-studio') { return ( ); } if (activeView === 'repaint') { return ( ); } if (activeView === 'song-builder') { return ( ); } if (activeView === 'insta-gen') { return (
{/* Insta-Gen Panel — resizable */} {/* Left resize handle */}
{ e.preventDefault(); const startX = e.clientX; const startW = createPanelWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(700, Math.max(360, startW + (ev.clientX - startX))); setCreatePanelWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{/* Song List — "Generations" (filtered to insta-gen only) */} getSongSource(s) === 'insta-gen')} currentSongId={currentSong?.id} onPlay={playSong} onDelete={handleDelete} onBulkDelete={handleBulkDelete} onSelect={(s) => { setSelectedSong(s); setShowRightSidebar(true); }} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDownload={handleDownload} onRename={handleRename} showFilters={false} viewMode="grid" title={t('app.generations')} onAddToPlaylist={(song) => { addToPlaylist({ id: song.id, title: song.title || 'Untitled', audioUrl: song.audioUrl || song.audio_url || '', masteredAudioUrl: song.masteredAudioUrl || song.mastered_audio_url || '', artistName: song.artistName || '', coverUrl: song.coverUrl || song.cover_url || '', duration: typeof song.duration === 'number' ? song.duration : undefined, style: song.style || '', }); showToast(t('app.addedToPlaylist'), 'success'); }} /> {/* Resize handle — Activity Sidebar */}
{ e.preventDefault(); const startX = e.clientX; const startW = activitySidebarWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(700, Math.max(240, startW + startX - ev.clientX)); setActivitySidebarWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{/* Activity Sidebar — Recent Songs + Queue */} {/* Right Sidebar — Song detail (same as Create) */} {showRightSidebar && selectedSong && ( <> {/* Right resize handle */}
{ e.preventDefault(); const startX = e.clientX; const startW = rightSidebarWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(600, Math.max(280, startW + startX - ev.clientX)); setRightSidebarWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowRightSidebar(false)} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDelete={handleDelete} onPlay={(song) => playFromList(songToTrack(song), songs.map(songToTrack), 'library')} isPlaying={isPlaying && currentTrack?.id === selectedSong?.id} onDownload={handleDownload} onRename={handleRename} /> )}
); } if (activeView === 'library') { return (
{ setSelectedSong(s); setShowRightSidebar(true); }} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDownload={handleDownload} onRename={handleRename} onAddToPlaylist={(song) => { addToPlaylist({ id: song.id, title: song.title || 'Untitled', audioUrl: song.audioUrl || song.audio_url || '', masteredAudioUrl: song.masteredAudioUrl || song.mastered_audio_url || '', artistName: song.artistName || '', coverUrl: song.coverUrl || song.cover_url || '', duration: typeof song.duration === 'number' ? song.duration : undefined, style: song.style || '', }); showToast(t('app.addedToPlaylist'), 'success'); }} /> {/* Right Sidebar — Song detail */} {showRightSidebar && selectedSong && ( <> {/* Resize handle */}
{ e.preventDefault(); const startX = e.clientX; const startW = rightSidebarWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(600, Math.max(280, startW + startX - ev.clientX)); setRightSidebarWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowRightSidebar(false)} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDelete={handleDelete} onPlay={(song) => playFromList(songToTrack(song), songs.map(songToTrack), 'library')} isPlaying={isPlaying && currentTrack?.id === selectedSong?.id} onDownload={handleDownload} onRename={handleRename} /> )}
); } // Default: create view return (
{/* Create Panel — resizable */} {/* Left resize handle */}
{ e.preventDefault(); const startX = e.clientX; const startW = createPanelWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(700, Math.max(360, startW + (ev.clientX - startX))); setCreatePanelWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{/* Song List + Queue (filtered to create/custom-gen only) */} getSongSource(s) === 'create')} currentSongId={currentSong?.id} onPlay={playSong} onDelete={handleDelete} onBulkDelete={handleBulkDelete} onSelect={(s) => { setSelectedSong(s); setShowRightSidebar(true); }} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDownload={handleDownload} onRename={handleRename} showFilters={false} viewMode="grid" title={t('app.generations')} onAddToPlaylist={(song) => { addToPlaylist({ id: song.id, title: song.title || 'Untitled', audioUrl: song.audioUrl || song.audio_url || '', masteredAudioUrl: song.masteredAudioUrl || song.mastered_audio_url || '', artistName: song.artistName || '', coverUrl: song.coverUrl || song.cover_url || '', duration: typeof song.duration === 'number' ? song.duration : undefined, style: song.style || '', }); showToast(t('app.addedToPlaylist'), 'success'); }} /> {/* Resize handle — Activity Sidebar */}
{ e.preventDefault(); const startX = e.clientX; const startW = activitySidebarWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(700, Math.max(240, startW + startX - ev.clientX)); setActivitySidebarWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{/* Activity Sidebar — Recent Songs + Queue */} {/* Right Sidebar — resizable */} {showRightSidebar && selectedSong && ( <> {/* Right resize handle */}
{ e.preventDefault(); const startX = e.clientX; const startW = rightSidebarWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(600, Math.max(280, startW + startX - ev.clientX)); setRightSidebarWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowRightSidebar(false)} onReuse={handleReuse} onSendToCover={handleSendToCover} onEditMetadata={handleEditMetadata} onDelete={handleDelete} onPlay={(song) => playFromList(songToTrack(song), songs.map(songToTrack), 'library')} isPlaying={isPlaying && currentTrack?.id === selectedSong?.id} onDownload={handleDownload} onRename={handleRename} /> )}
); }; return (
{/* Global Parameter Bar — full width, above everything */}
{ setConfirmDialog({ title: t('app.quitDialog.title'), message: t('app.quitDialog.message'), confirmLabel: t('app.quitDialog.confirm'), danger: true, onConfirm: async () => { setConfirmDialog(null); try { await fetch('/api/shutdown', { method: 'POST' }); } catch { /* shutting down */ } setIsShutdown(true); }, }); }} onRestart={() => { setConfirmDialog({ title: t('app.restartDialog.title'), message: t('app.restartDialog.message'), confirmLabel: t('app.restartDialog.confirm'), danger: false, onConfirm: async () => { setConfirmDialog(null); setIsRestarting(true); try { await fetch('/api/shutdown/restart', { method: 'POST' }); } catch { /* restarting */ } }, }); }} theme={theme} onToggleTheme={toggleTheme} showTerminal={showTerminal} onToggleTerminal={() => setShowTerminal(prev => !prev)} showAssistant={showAssistant} onToggleAssistant={() => setShowAssistant(prev => !prev)} />
{renderContent()}
{/* Playlist Sidebar — right of main, left of terminal */} {showPlaylist && ( <>
{ e.preventDefault(); const startX = e.clientX; const startW = playlistWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(600, Math.max(220, startW + startX - ev.clientX)); setPlaylistWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowPlaylist(false)} /> )} {/* Assistant Panel — between playlist and terminal, resizable */} {showAssistant && ( <>
{ e.preventDefault(); const startX = e.clientX; const startW = assistantWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(700, Math.max(320, startW + startX - ev.clientX)); setAssistantWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowAssistant(false)} activeView={activeView} /> )} {/* Terminal Panel — far right, resizable */} {showTerminal && ( <>
{ e.preventDefault(); const startX = e.clientX; const startW = terminalWidth; const onMove = (ev: MouseEvent) => { const newW = Math.min(900, Math.max(300, startW + startX - ev.clientX)); setTerminalWidth(newW); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
setShowTerminal(false)} /> )}
{/* ── Bottom Player Area: Markers → Waveform → Transport ── */} {/* Collapsible visualisation area — animates up when playing, down when paused/stopped. Uses CSS Grid 0fr→1fr trick so the transition tracks actual content height perfectly, unlike max-height which over-shoots and makes the expand feel instant. */}
{trimMode && ( pbSetTrimMode(false)} /> )} {/* Dual waveform: original + mastered stacked, opacity-switched */}
{}} onPlayChange={pbSetIsPlaying} onFinish={pbHandleFinish} onWaveformClick={handleWaveformClick} onReady={(dur) => { handleOriginalReady(dur); if (!playMastered) { setSpectrumMediaEl(wavesurferRef.current?.getMediaElement() ?? null); } }} />
{}} onPlayChange={pbSetIsPlaying} onFinish={pbHandleFinish} onWaveformClick={handleWaveformClick} onReady={(dur) => { handleAltReady(dur); if (playMastered) { setSpectrumMediaEl(wavesurferAltRef.current?.getMediaElement() ?? null); } }} />
{/* /inner overflow wrapper */}
{/* Global A/B comparison mini-bar — visible from any view when both tracks are pinned */} pbSetShuffle(!shuffle)} repeatMode={repeat} onToggleRepeat={pbCycleRepeat} onReusePrompt={() => currentSong && handleReuse(currentSong as Song)} onDelete={() => currentSong && handleDelete(currentSong as Song)} onDownload={() => currentSong && downloadTrack(currentSong as Song)} playMastered={playMastered} onToggleMastered={pbToggleMastered} spectrumEnabled={spectrumEnabled} onToggleSpectrum={() => pbSetSpectrumEnabled(!spectrumEnabled)} showPlaylist={showPlaylist} playlistCount={playlistData.items.length} onTogglePlaylist={() => setShowPlaylist(prev => !prev)} trimMode={trimMode} onToggleTrimMode={() => pbSetTrimMode(!trimMode)} abMode={abMode} abActiveLabel={abActiveLabel} onToggleAB={pbToggleAB} onExitABMode={pbExitABMode} discoMode={discoMode} onToggleDisco={handleToggleDisco} />
{/* Modals */} setToast(prev => ({ ...prev, isVisible: false }))} /> confirmDialog?.onConfirm()} onCancel={() => setConfirmDialog(null)} /> {/* Per-track cover art prompt modal (#67) — single instance, driven by window events */} {metadataEditSong && token && ( setMetadataEditSong(null)} onSaved={(s) => { handleSongUpdate(s); setMetadataEditSong(null); }} /> )}
); }; /** Root App — wraps content in GlobalParamsProvider + DisguiseModeProvider */ const App: React.FC = () => ( ); export default App;