// 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}
▶ Play A/B
↔ Compare
✕
);
};
/** 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 (
);
}
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;