Files
hot-step-cpp-ROCm/ui/src/App.tsx
T
2026-08-16 18:24:52 +07:00

1482 lines
62 KiB
TypeScript

// 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 (
<div className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-white dark:bg-black text-zinc-900 dark:text-white">
{/* Animated spinner */}
<div className="relative mb-8">
<div className="w-16 h-16 rounded-full border-4 border-amber-500/20" />
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-amber-400 animate-spin" />
</div>
<h1 className="text-2xl font-bold mb-2">{t('app.restarting.title')}</h1>
<p className="text-zinc-600 dark:text-zinc-400 text-lg">{status}{dots}</p>
<p className="text-zinc-600 text-sm mt-4">{t('app.restarting.message')}</p>
</div>
);
};
/** 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 (
<div className="flex items-center gap-2 px-4 py-1.5 bg-gradient-to-r from-blue-500/5 via-transparent to-orange-500/5 border-t border-white/5">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-blue-500/10 text-blue-400 border border-blue-500/20">
A <span className="font-normal truncate max-w-[80px]">{trackA.title}</span>
</span>
<span className="text-[9px] text-zinc-600">vs</span>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-orange-500/10 text-orange-400 border border-orange-500/20">
B <span className="font-normal truncate max-w-[80px]">{trackB.title}</span>
</span>
<div className="flex-1" />
<button
onClick={playAB}
className="px-2 py-1 rounded-md text-[9px] font-semibold bg-pink-500/10 text-pink-400 border border-pink-500/20 hover:bg-pink-500/20 transition-colors"
>
Play A/B
</button>
<button
onClick={openABModal}
className="px-2 py-1 rounded-md text-[9px] font-semibold bg-violet-500/10 text-violet-400 border border-violet-500/20 hover:bg-violet-500/20 transition-colors"
>
Compare
</button>
<button
onClick={clearAB}
className="p-1 rounded-md text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Clear A/B"
>
</button>
</div>
);
};
/** 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<Song[]>([]);
const [selectedSong, setSelectedSong] = useState<Song | null>(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<AppSettings>('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<WaveformPlayerHandle>(null);
const wavesurferAltRef = useRef<WaveformPlayerHandle>(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<HTMLMediaElement | null>(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<GenerationParams>) => {
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<Song | null>(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 (
<div className="flex h-screen items-center justify-center bg-white dark:bg-suno text-zinc-600 dark:text-zinc-400">
<div className="text-center">
<div className="text-4xl mb-4"></div>
<div className="text-lg font-medium">{t('app.loading')}</div>
</div>
</div>
);
}
if (isShutdown) {
return (
<div className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-white dark:bg-black text-zinc-900 dark:text-white">
<div className="text-6xl mb-6">👋</div>
<h1 className="text-2xl font-bold mb-2">{t('app.shutdown.title')}</h1>
<p className="text-zinc-600 dark:text-zinc-400">{t('app.shutdown.message')}</p>
</div>
);
}
if (isRestarting) {
return <RestartingOverlay />;
}
// Main content renderer
const renderContent = () => {
if (activeView === 'settings') {
return (
<DiscoPulseWrapper hue={DISCO.activity} className="flex-1 overflow-y-auto">
<SettingsPanel
settings={settings}
onSettingsChange={setSettings}
onNukeComplete={() => {
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();
}}
/>
</DiscoPulseWrapper>
);
}
if (activeView === 'lyric-studio') {
return (
<DiscoPulseWrapper hue={DISCO.songGrid} className="flex-1 overflow-hidden">
<LyricStudioV2 />
</DiscoPulseWrapper>
);
}
if (activeView === 'cover-studio') {
return (
<DiscoPulseWrapper hue={DISCO.activity} className="flex-1 overflow-hidden">
<CoverStudio coverSource={coverSource} />
</DiscoPulseWrapper>
);
}
if (activeView === 'stem-studio') {
return (
<DiscoPulseWrapper hue={DISCO.assistant} className="flex-1 overflow-hidden">
<StemStudio />
</DiscoPulseWrapper>
);
}
if (activeView === 'stem-builder') {
return (
<DiscoPulseWrapper hue={DISCO.assistant} className="flex-1 overflow-hidden">
<StemBuilder />
</DiscoPulseWrapper>
);
}
if (activeView === 'storm') {
return (
<DiscoPulseWrapper hue={DISCO.storm} className="flex-1 overflow-hidden">
<StormPage onGenerate={handleGenerate} activeJobCount={activeJobCount} />
</DiscoPulseWrapper>
);
}
if (activeView === 'midi-studio') {
return (
<DiscoPulseWrapper hue={DISCO.playlist} className="flex-1 overflow-hidden">
<MidiStudio />
</DiscoPulseWrapper>
);
}
if (activeView === 'training-studio') {
return (
<DiscoPulseWrapper hue={DISCO.trainingStudio} className="flex-1 overflow-hidden">
<TrainingStudio />
</DiscoPulseWrapper>
);
}
if (activeView === 'repaint') {
return (
<DiscoPulseWrapper hue={DISCO.createPanel} className="flex-1 overflow-hidden">
<RepaintStudio />
</DiscoPulseWrapper>
);
}
if (activeView === 'song-builder') {
return (
<DiscoPulseWrapper hue={DISCO.createPanel} className="flex-1 overflow-hidden">
<SongBuilder />
</DiscoPulseWrapper>
);
}
if (activeView === 'insta-gen') {
return (
<div className="flex flex-1 overflow-hidden">
{/* Insta-Gen Panel — resizable */}
<DiscoPulseWrapper hue={DISCO.createPanel}
className="flex-shrink-0 h-full border-r border-zinc-200 dark:border-white/5"
style={{ width: createPanelWidth }}
>
<InstaGenPanel
onSongCreated={handleSongCreated}
activeJobCount={activeJobCount}
onNavigate={navigateTo}
/>
</DiscoPulseWrapper>
{/* Left resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Song List — "Generations" (filtered to insta-gen only) */}
<DiscoPulseWrapper hue={DISCO.songGrid} className="flex-1 min-w-0 overflow-y-auto">
<SongList
songs={songs.filter(s => 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');
}}
/>
</DiscoPulseWrapper>
{/* Resize handle — Activity Sidebar */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Activity Sidebar — Recent Songs + Queue */}
<DiscoPulseWrapper hue={DISCO.activity} className="h-full flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden" style={{ width: activitySidebarWidth }}>
<ActivitySidebar
source="insta-gen"
showToast={showToast}
refreshKey={songCreatedCount}
compact={activitySidebarWidth < 380}
/>
</DiscoPulseWrapper>
{/* Right Sidebar — Song detail (same as Create) */}
{showRightSidebar && selectedSong && (
<>
{/* Right resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.rightSidebar}
className="flex-shrink-0 h-full bg-zinc-50 dark:bg-suno z-10 border-l border-zinc-200 dark:border-white/5"
style={{ width: rightSidebarWidth }}
>
<RightSidebar
song={selectedSong}
onClose={() => 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}
/>
</DiscoPulseWrapper>
</>
)}
</div>
);
}
if (activeView === 'library') {
return (
<div className="flex flex-1 overflow-hidden">
<DiscoPulseWrapper hue={DISCO.songGrid} className="flex-1 min-w-0 overflow-y-auto">
<SongList
songs={songs}
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}
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');
}}
/>
</DiscoPulseWrapper>
{/* Right Sidebar — Song detail */}
{showRightSidebar && selectedSong && (
<>
{/* Resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.rightSidebar}
className="flex-shrink-0 h-full bg-zinc-50 dark:bg-suno z-10 border-l border-zinc-200 dark:border-white/5"
style={{ width: rightSidebarWidth }}
>
<RightSidebar
song={selectedSong}
onClose={() => 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}
/>
</DiscoPulseWrapper>
</>
)}
</div>
);
}
// Default: create view
return (
<div className="flex flex-1 overflow-hidden">
{/* Create Panel — resizable */}
<DiscoPulseWrapper hue={DISCO.createPanel} className="flex-shrink-0 h-full border-r border-zinc-200 dark:border-white/5" style={{ width: createPanelWidth }}>
<CreatePanel
onGenerate={handleGenerate}
activeJobCount={activeJobCount}
reuseData={reuseData}
streamJobId={streamJobId}
/>
</DiscoPulseWrapper>
{/* Left resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Song List + Queue (filtered to create/custom-gen only) */}
<DiscoPulseWrapper hue={DISCO.songGrid} className="flex-1 min-w-0 overflow-y-auto">
<SongList
songs={songs.filter(s => 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');
}}
/>
</DiscoPulseWrapper>
{/* Resize handle — Activity Sidebar */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Activity Sidebar — Recent Songs + Queue */}
<DiscoPulseWrapper hue={DISCO.activity} className="h-full flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden" style={{ width: activitySidebarWidth }}>
<ActivitySidebar
source="create"
showToast={showToast}
refreshKey={songCreatedCount}
compact={activitySidebarWidth < 380}
/>
</DiscoPulseWrapper>
{/* Right Sidebar — resizable */}
{showRightSidebar && selectedSong && (
<>
{/* Right resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.rightSidebar}
className="flex-shrink-0 h-full bg-zinc-50 dark:bg-suno z-10 border-l border-zinc-200 dark:border-white/5"
style={{ width: rightSidebarWidth }}
>
<RightSidebar
song={selectedSong}
onClose={() => 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}
/>
</DiscoPulseWrapper>
</>
)}
</div>
);
};
return (
<div className="flex flex-col h-screen bg-white dark:bg-suno text-zinc-900 dark:text-white font-sans antialiased selection:bg-pink-500/30 transition-all duration-300">
{/* Global Parameter Bar — full width, above everything */}
<GlobalParamBar />
<div className="flex-1 flex overflow-hidden">
<DiscoPulseWrapper hue={DISCO.sidebar}>
<Sidebar
activeView={activeView}
onViewChange={navigateTo}
onQuit={() => {
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)}
/>
</DiscoPulseWrapper>
<main className="flex-1 flex overflow-hidden relative">
{renderContent()}
</main>
{/* Playlist Sidebar — right of main, left of terminal */}
{showPlaylist && (
<>
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.playlist}
className="flex-shrink-0 h-full border-l border-zinc-200 dark:border-white/5"
style={{ width: playlistWidth }}
>
<PlaylistSidebar onClose={() => setShowPlaylist(false)} />
</DiscoPulseWrapper>
</>
)}
{/* Assistant Panel — between playlist and terminal, resizable */}
{showAssistant && (
<>
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-violet-500/20 active:bg-violet-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-violet-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.assistant}
className="flex-shrink-0 h-full border-l border-zinc-200 dark:border-white/5"
style={{ width: assistantWidth }}
>
<AssistantPanel onClose={() => setShowAssistant(false)} activeView={activeView} />
</DiscoPulseWrapper>
</>
)}
{/* Terminal Panel — far right, resizable */}
{showTerminal && (
<>
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-emerald-500/20 active:bg-emerald-500/30 transition-colors"
onMouseDown={(e) => {
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);
}}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-emerald-400 transition-colors" />
</div>
<DiscoPulseWrapper hue={DISCO.terminal}
className="flex-shrink-0 h-full border-l border-zinc-200 dark:border-white/5"
style={{ width: terminalWidth }}
>
<TerminalPanel onClose={() => setShowTerminal(false)} />
</DiscoPulseWrapper>
</>
)}
</div>
{/* ── Bottom Player Area: Markers → Waveform → Transport ── */}
<DiscoPulseWrapper hue={DISCO.player} className="flex-shrink-0 bg-white dark:bg-zinc-950 border-t border-zinc-200 dark:border-white/5" style={{ position: 'relative' }}>
{/* 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. */}
<div
style={{
display: 'grid',
gridTemplateRows: (playerActive || trimMode) ? '1fr' : '0fr',
opacity: (playerActive || trimMode) ? 1 : 0,
transition: 'grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease',
}}
>
<div style={{ overflow: 'hidden', minHeight: 0 }}>
<SectionMarkers audioUrl={currentTrack?.audioUrl ?? undefined} duration={duration} />
<SpectrumAnalyzer
mediaElement={spectrumMediaEl}
visible={spectrumEnabled && playerActive}
isPlaying={isPlaying}
/>
{trimMode && (
<TrimControls
trimInPoint={trimInPoint}
trimOutPoint={trimOutPoint}
trimClickCount={trimClickCount}
duration={duration}
songId={currentTrack?.id ?? null}
audioUrl={currentTrack?.audioUrl ?? null}
wavesurferRef={wavesurferRef}
wavesurferAltRef={wavesurferAltRef}
onReload={pbReloadCurrentTrack}
onCancel={() => pbSetTrimMode(false)}
/>
)}
{/* Dual waveform: original + mastered stacked, opacity-switched */}
<div className="relative" style={{ height: 56 }}>
<div style={{
position: 'absolute', inset: 0,
opacity: playMastered ? 0 : 1,
pointerEvents: playMastered ? 'none' : 'auto',
transition: 'opacity 0.15s ease',
}}>
<WaveformPlayer
ref={wavesurferRef}
volume={playMastered ? 0 : volume}
playbackRate={playbackRate}
onTimeUpdate={pbSetCurrentTime}
onDurationChange={() => {}}
onPlayChange={pbSetIsPlaying}
onFinish={pbHandleFinish}
onWaveformClick={handleWaveformClick}
onReady={(dur) => {
handleOriginalReady(dur);
if (!playMastered) {
setSpectrumMediaEl(wavesurferRef.current?.getMediaElement() ?? null);
}
}}
/>
</div>
<div style={{
position: 'absolute', inset: 0,
opacity: playMastered ? 1 : 0,
pointerEvents: playMastered ? 'auto' : 'none',
transition: 'opacity 0.15s ease',
}}>
<WaveformPlayer
ref={wavesurferAltRef}
volume={playMastered ? volume : 0}
playbackRate={playbackRate}
onTimeUpdate={pbSetCurrentTime}
onDurationChange={() => {}}
onPlayChange={pbSetIsPlaying}
onFinish={pbHandleFinish}
onWaveformClick={handleWaveformClick}
onReady={(dur) => {
handleAltReady(dur);
if (playMastered) {
setSpectrumMediaEl(wavesurferAltRef.current?.getMediaElement() ?? null);
}
}}
/>
</div>
</div>
</div>
{/* /inner overflow wrapper */}
</div>
<LyricsBar
audioUrl={currentTrack?.audioUrl ?? undefined}
currentTime={currentTime}
isPlaying={isPlaying}
/>
{/* Global A/B comparison mini-bar — visible from any view when both tracks are pinned */}
<ABMiniBar />
<Player
currentSong={currentSong}
isPlaying={isPlaying}
onTogglePlay={pbTogglePlay}
onStop={pbStop}
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
onNext={pbNext}
onPrevious={pbPrevious}
volume={volume}
onVolumeChange={pbSetVolume}
playbackRate={playbackRate}
onPlaybackRateChange={pbSetPlaybackRate}
audioRef={wavesurferRef as any}
isShuffle={shuffle}
onToggleShuffle={() => 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}
/>
<HiHatParticles />
</DiscoPulseWrapper>
{/* Modals */}
<Toast
message={toast.message}
type={toast.type}
isVisible={toast.isVisible}
onClose={() => setToast(prev => ({ ...prev, isVisible: false }))}
/>
<ConfirmDialog
isOpen={confirmDialog !== null}
title={confirmDialog?.title ?? ''}
message={confirmDialog?.message ?? ''}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={() => confirmDialog?.onConfirm()}
onCancel={() => setConfirmDialog(null)}
/>
<ABCompareModal />
{/* Per-track cover art prompt modal (#67) — single instance, driven by window events */}
<CoverArtPromptModal />
{metadataEditSong && token && (
<MetadataEditorModal
song={metadataEditSong}
token={token}
onClose={() => setMetadataEditSong(null)}
onSaved={(s) => { handleSongUpdate(s); setMetadataEditSong(null); }}
/>
)}
</div>
);
};
/** Root App — wraps content in GlobalParamsProvider + DisguiseModeProvider */
const App: React.FC = () => (
<GlobalParamsProvider>
<DisguiseModeProvider>
<AppContent />
</DisguiseModeProvider>
</GlobalParamsProvider>
);
export default App;