Files
ace-step-ui/App.tsx
T
fspecii 325046eaf2 Apply i18n to all components, add collapsible sidebar, playback speed control, inline title editing, and ConfirmDialog for deletions
Phase 4: i18n all 15 components from PR #19. Sidebar gains collapse/expand
toggle. Player gets playback speed selector (0.25x-2.0x) fixing bug B1
(hardcoded Chinese '正常'). SongList adds inline title editing and model
version badge. Delete actions now use ConfirmDialog instead of
window.confirm. Volume persisted to localStorage. Training nav item
deferred to Phase 7.
2026-02-08 18:35:09 +02:00

1439 lines
47 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Sidebar } from './components/Sidebar';
import { CreatePanel } from './components/CreatePanel';
import { SongList } from './components/SongList';
import { RightSidebar } from './components/RightSidebar';
import { Player } from './components/Player';
import { LibraryView } from './components/LibraryView';
import { CreatePlaylistModal, AddToPlaylistModal } from './components/PlaylistModals';
import { VideoGeneratorModal } from './components/VideoGeneratorModal';
import { UsernameModal } from './components/UsernameModal';
import { UserProfile } from './components/UserProfile';
import { SettingsModal } from './components/SettingsModal';
import { SongProfile } from './components/SongProfile';
import { Song, GenerationParams, View, Playlist } from './types';
import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api';
import { useAuth } from './context/AuthContext';
import { useResponsive } from './context/ResponsiveContext';
import { I18nProvider, useI18n } from './context/I18nContext';
import { List } from 'lucide-react';
import { PlaylistDetail } from './components/PlaylistDetail';
import { Toast, ToastType } from './components/Toast';
import { SearchPage } from './components/SearchPage';
import { ConfirmDialog } from './components/ConfirmDialog';
function AppContent() {
// i18n
const { t } = useI18n();
// Responsive
const { isMobile, isDesktop } = useResponsive();
// Auth
const { user, token, isAuthenticated, isLoading: authLoading, setupUser, logout } = useAuth();
const [showUsernameModal, setShowUsernameModal] = useState(false);
// Track multiple concurrent generation jobs
const activeJobsRef = useRef<Map<string, { tempId: string; pollInterval: ReturnType<typeof setInterval> }>>(new Map());
const [activeJobCount, setActiveJobCount] = useState(0);
// Theme State
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
// Navigation State - default to create view
const [currentView, setCurrentView] = useState<View>('create');
// Content State
const [songs, setSongs] = useState<Song[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [likedSongIds, setLikedSongIds] = useState<Set<string>>(new Set());
const [referenceTracks, setReferenceTracks] = useState<ReferenceTrack[]>([]);
const [playQueue, setPlayQueue] = useState<Song[]>([]);
const [queueIndex, setQueueIndex] = useState(-1);
// Selection State
const [currentSong, setCurrentSong] = useState<Song | null>(null);
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const [selectedPlaylist, setSelectedPlaylist] = useState<Playlist | null>(null);
// Player State
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(() => {
const stored = localStorage.getItem('volume');
return stored ? parseFloat(stored) : 0.8;
});
const [playbackRate, setPlaybackRate] = useState(1.0);
const [isShuffle, setIsShuffle] = useState(false);
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all');
// UI State
const [isGenerating, setIsGenerating] = useState(false);
const [showRightSidebar, setShowRightSidebar] = useState(true);
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null);
// Mobile UI Toggle
const [mobileShowList, setMobileShowList] = useState(false);
// Modals
const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false);
const [isAddToPlaylistModalOpen, setIsAddToPlaylistModalOpen] = useState(false);
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null);
// Video Modal
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
const [songForVideo, setSongForVideo] = useState<Song | null>(null);
// Settings Modal
const [showSettingsModal, setShowSettingsModal] = useState(false);
// Profile View
const [viewingUsername, setViewingUsername] = useState<string | null>(null);
// Song View
const [viewingSongId, setViewingSongId] = useState<string | null>(null);
// Playlist View
const [viewingPlaylistId, setViewingPlaylistId] = useState<string | null>(null);
// Reuse State
const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentSongIdRef = useRef<string | null>(null);
const pendingSeekRef = useRef<number | null>(null);
const playNextRef = useRef<() => void>(() => {});
// Mobile Details Modal State
const [showMobileDetails, setShowMobileDetails] = useState(false);
// 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;
onConfirm: () => void;
} | null>(null);
interface ReferenceTrack {
id: string;
filename: string;
storage_key: string;
duration: number | null;
file_size_bytes: number | null;
tags: string[] | null;
created_at: string;
audio_url: string;
}
const showToast = (message: string, type: ToastType = 'success') => {
setToast({ message, type, isVisible: true });
};
const closeToast = () => {
setToast(prev => ({ ...prev, isVisible: false }));
};
// Show username modal if not authenticated and not loading
useEffect(() => {
if (!authLoading && !isAuthenticated) {
setShowUsernameModal(true);
}
}, [authLoading, isAuthenticated]);
// Load Playlists
useEffect(() => {
if (token) {
playlistsApi.getMyPlaylists(token)
.then(res => setPlaylists(res.playlists))
.catch(err => console.error('Failed to load playlists', err));
} else {
setPlaylists([]);
}
}, [token]);
// Cleanup active jobs on unmount
useEffect(() => {
return () => {
// Clear all polling intervals when component unmounts
activeJobsRef.current.forEach(({ pollInterval }) => {
clearInterval(pollInterval);
});
activeJobsRef.current.clear();
};
}, []);
const handleShowDetails = (song: Song) => {
setSelectedSong(song);
setShowMobileDetails(true);
};
// Reuse Handler
const handleReuse = (song: Song) => {
setReuseData({ song, timestamp: Date.now() });
setCurrentView('create');
setMobileShowList(false);
};
// Song Update Handler
const handleSongUpdate = (updatedSong: Song) => {
setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s));
if (currentSong?.id === updatedSong.id) {
setCurrentSong(updatedSong);
}
if (selectedSong?.id === updatedSong.id) {
setSelectedSong(updatedSong);
}
};
// Navigate to Profile Handler
const handleNavigateToProfile = (username: string) => {
setViewingUsername(username);
setCurrentView('profile');
window.history.pushState({}, '', `/@${username}`);
};
// Back from Profile Handler
const handleBackFromProfile = () => {
setViewingUsername(null);
setCurrentView('create');
window.history.pushState({}, '', '/');
};
// Navigate to Song Handler
const handleNavigateToSong = (songId: string) => {
setViewingSongId(songId);
setCurrentView('song');
window.history.pushState({}, '', `/song/${songId}`);
};
// Back from Song Handler
const handleBackFromSong = () => {
setViewingSongId(null);
setCurrentView('create');
window.history.pushState({}, '', '/');
};
// Theme Effect
useEffect(() => {
localStorage.setItem('theme', theme);
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
// URL Routing Effect
useEffect(() => {
const handleUrlChange = () => {
const path = window.location.pathname;
const params = new URLSearchParams(window.location.search);
// Handle ?song= query parameter
const songParam = params.get('song');
if (songParam) {
setViewingSongId(songParam);
setCurrentView('song');
window.history.replaceState({}, '', `/song/${songParam}`);
return;
}
if (path === '/create' || path === '/') {
setCurrentView('create');
setMobileShowList(false);
} else if (path === '/library') {
setCurrentView('library');
} else if (path.startsWith('/@')) {
const username = path.substring(2);
if (username) {
setViewingUsername(username);
setCurrentView('profile');
}
} else if (path.startsWith('/song/')) {
const songId = path.substring(6);
if (songId) {
setViewingSongId(songId);
setCurrentView('song');
}
} else if (path.startsWith('/playlist/')) {
const playlistId = path.substring(10);
if (playlistId) {
setViewingPlaylistId(playlistId);
setCurrentView('playlist');
}
} else if (path === '/search') {
setCurrentView('search');
}
};
handleUrlChange();
window.addEventListener('popstate', handleUrlChange);
return () => window.removeEventListener('popstate', handleUrlChange);
}, []);
// Load Songs Effect
useEffect(() => {
if (!isAuthenticated || !token) return;
const loadSongs = async () => {
try {
const [mySongsRes, likedSongsRes] = await Promise.all([
songsApi.getMySongs(token),
songsApi.getLikedSongs(token)
]);
const mapSong = (s: any): Song => ({
id: s.id,
title: s.title,
lyrics: s.lyrics,
style: s.style,
coverUrl: `https://picsum.photos/seed/${s.id}/400/400`,
duration: s.duration && s.duration > 0 ? `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}` : '0:00',
createdAt: new Date(s.created_at || s.createdAt),
tags: s.tags || [],
audioUrl: getAudioUrl(s.audio_url, s.id),
isPublic: s.is_public,
likeCount: s.like_count || 0,
viewCount: s.view_count || 0,
userId: s.user_id,
creator: s.creator,
ditModel: s.ditModel,
generationParams: (() => {
try {
if (!s.generation_params) return undefined;
return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params;
} catch {
return undefined;
}
})(),
});
const mySongs = mySongsRes.songs.map(mapSong);
const likedSongs = likedSongsRes.songs.map(mapSong);
const songsMap = new Map<string, Song>();
[...mySongs, ...likedSongs].forEach(s => songsMap.set(s.id, s));
// Preserve any generating songs (temp songs)
setSongs(prev => {
const generatingSongs = prev.filter(s => s.isGenerating);
const loadedSongs = Array.from(songsMap.values());
return [...generatingSongs, ...loadedSongs];
});
const likedIds = new Set(likedSongs.map(s => s.id));
setLikedSongIds(likedIds);
} catch (error) {
console.error('Failed to load songs:', error);
}
};
loadSongs();
}, [isAuthenticated, token]);
const loadReferenceTracks = useCallback(async () => {
if (!isAuthenticated || !token) return;
try {
const response = await fetch('/api/reference-tracks', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) return;
const data = await response.json();
setReferenceTracks(data.tracks || []);
} catch (error) {
console.error('Failed to load reference tracks:', error);
}
}, [isAuthenticated, token]);
// Load reference tracks for Library
useEffect(() => {
loadReferenceTracks();
}, [loadReferenceTracks]);
useEffect(() => {
if (currentView === 'library') {
loadReferenceTracks();
}
}, [currentView, loadReferenceTracks]);
// Player Logic
const getActiveQueue = (song?: Song) => {
if (playQueue.length > 0) return playQueue;
if (song && songs.some(s => s.id === song.id)) return songs;
return songs;
};
const playNext = useCallback(() => {
if (!currentSong) return;
const queue = getActiveQueue(currentSong);
if (queue.length === 0) return;
const currentIndex = queueIndex >= 0 && queue[queueIndex]?.id === currentSong.id
? queueIndex
: queue.findIndex(s => s.id === currentSong.id);
if (currentIndex === -1) return;
if (repeatMode === 'one') {
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play();
}
return;
}
let nextIndex;
if (isShuffle) {
do {
nextIndex = Math.floor(Math.random() * queue.length);
} while (queue.length > 1 && nextIndex === currentIndex);
} else {
nextIndex = (currentIndex + 1) % queue.length;
}
const nextSong = queue[nextIndex];
setQueueIndex(nextIndex);
setCurrentSong(nextSong);
setIsPlaying(true);
}, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs]);
const playPrevious = useCallback(() => {
if (!currentSong) return;
const queue = getActiveQueue(currentSong);
if (queue.length === 0) return;
const currentIndex = queueIndex >= 0 && queue[queueIndex]?.id === currentSong.id
? queueIndex
: queue.findIndex(s => s.id === currentSong.id);
if (currentIndex === -1) return;
if (currentTime > 3) {
if (audioRef.current) audioRef.current.currentTime = 0;
return;
}
let prevIndex = (currentIndex - 1 + queue.length) % queue.length;
if (isShuffle) {
prevIndex = Math.floor(Math.random() * queue.length);
}
const prevSong = queue[prevIndex];
setQueueIndex(prevIndex);
setCurrentSong(prevSong);
setIsPlaying(true);
}, [currentSong, queueIndex, currentTime, isShuffle, playQueue, songs]);
useEffect(() => {
playNextRef.current = playNext;
}, [playNext]);
// Audio Setup
useEffect(() => {
audioRef.current = new Audio();
audioRef.current.crossOrigin = "anonymous";
const audio = audioRef.current;
audio.volume = volume;
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const applyPendingSeek = () => {
if (pendingSeekRef.current === null) return;
if (audio.seekable.length === 0) return;
const target = pendingSeekRef.current;
const safeTarget = Number.isFinite(audio.duration)
? Math.min(Math.max(target, 0), audio.duration)
: Math.max(target, 0);
audio.currentTime = safeTarget;
setCurrentTime(safeTarget);
pendingSeekRef.current = null;
};
const onLoadedMetadata = () => {
setDuration(audio.duration);
applyPendingSeek();
};
const onCanPlay = () => {
applyPendingSeek();
};
const onProgress = () => {
applyPendingSeek();
};
const onEnded = () => {
playNextRef.current();
};
const onError = (e: Event) => {
if (audio.error && audio.error.code !== 1) {
console.error("Audio playback error:", audio.error);
if (audio.error.code === 4) {
showToast(t('songNotAvailable'), 'error');
} else {
showToast(t('unableToPlay'), 'error');
}
}
setIsPlaying(false);
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('progress', onProgress);
audio.addEventListener('ended', onEnded);
audio.addEventListener('error', onError);
return () => {
audio.pause();
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('canplay', onCanPlay);
audio.removeEventListener('progress', onProgress);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
};
}, []);
// Handle Playback State
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentSong?.audioUrl) return;
const playAudio = async () => {
try {
await audio.play();
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error("Playback failed:", err);
if (err.name === 'NotSupportedError') {
showToast(t('songNotAvailable'), 'error');
}
setIsPlaying(false);
}
}
};
if (currentSongIdRef.current !== currentSong.id) {
currentSongIdRef.current = currentSong.id;
audio.src = currentSong.audioUrl;
audio.load();
if (isPlaying) playAudio();
} else {
if (isPlaying) playAudio();
else audio.pause();
}
}, [currentSong, isPlaying]);
// Handle Volume
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume;
}
localStorage.setItem('volume', String(volume));
}, [volume]);
// Handle Playback Rate
useEffect(() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}, [playbackRate]);
// Helper to cleanup a job and check if all jobs are done
const cleanupJob = useCallback((jobId: string, tempId: string) => {
const jobData = activeJobsRef.current.get(jobId);
if (jobData) {
clearInterval(jobData.pollInterval);
activeJobsRef.current.delete(jobId);
}
// Remove temp song
setSongs(prev => prev.filter(s => s.id !== tempId));
// Update active job count
setActiveJobCount(activeJobsRef.current.size);
// If no more active jobs, set isGenerating to false
if (activeJobsRef.current.size === 0) {
setIsGenerating(false);
}
}, []);
// Refresh songs list (called when any job completes successfully)
const refreshSongsList = useCallback(async () => {
if (!token) return;
try {
const response = await songsApi.getMySongs(token);
const loadedSongs: Song[] = response.songs.map(s => ({
id: s.id,
title: s.title,
lyrics: s.lyrics,
style: s.style,
coverUrl: `https://picsum.photos/seed/${s.id}/400/400`,
duration: s.duration && s.duration > 0 ? `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}` : '0:00',
createdAt: new Date(s.created_at),
tags: s.tags || [],
audioUrl: getAudioUrl(s.audio_url, s.id),
isPublic: s.is_public,
likeCount: s.like_count || 0,
viewCount: s.view_count || 0,
userId: s.user_id,
creator: s.creator,
ditModel: s.ditModel,
generationParams: (() => {
try {
if (!s.generation_params) return undefined;
return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params;
} catch {
return undefined;
}
})(),
}));
// Preserve any generating songs that aren't in the loaded list
setSongs(prev => {
const generatingSongs = prev.filter(s => s.isGenerating);
const mergedSongs = [...generatingSongs];
for (const song of loadedSongs) {
if (!mergedSongs.some(s => s.id === song.id)) {
mergedSongs.push(song);
}
}
// Sort by creation date, newest first
return mergedSongs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
});
// If the current selection was a temp/generating song, replace it with newest real song
if (selectedSong?.isGenerating || (selectedSong && !loadedSongs.some(s => s.id === selectedSong.id))) {
setSelectedSong(loadedSongs[0] ?? null);
}
} catch (error) {
console.error('Failed to refresh songs:', error);
}
}, [token]);
const beginPollingJob = useCallback((jobId: string, tempId: string) => {
if (!token) return;
if (activeJobsRef.current.has(jobId)) return;
const pollInterval = setInterval(async () => {
try {
const status = await generateApi.getStatus(jobId, token);
const normalizedProgress = Number.isFinite(Number(status.progress))
? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress))
: undefined;
setSongs(prev => prev.map(s => {
if (s.id === tempId) {
return {
...s,
queuePosition: status.status === 'queued' ? status.queuePosition : undefined,
progress: normalizedProgress ?? s.progress,
stage: status.stage ?? s.stage,
};
}
return s;
}));
if (status.status === 'succeeded' && status.result) {
cleanupJob(jobId, tempId);
await refreshSongsList();
if (window.innerWidth < 768) {
setMobileShowList(true);
}
} else if (status.status === 'failed') {
cleanupJob(jobId, tempId);
console.error(`Job ${jobId} failed:`, status.error);
showToast(`${t('generationFailed')}: ${status.error || 'Unknown error'}`, 'error');
}
} catch (pollError) {
console.error(`Polling error for job ${jobId}:`, pollError);
cleanupJob(jobId, tempId);
}
}, 2000);
activeJobsRef.current.set(jobId, { tempId, pollInterval });
setActiveJobCount(activeJobsRef.current.size);
setTimeout(() => {
if (activeJobsRef.current.has(jobId)) {
console.warn(`Job ${jobId} timed out`);
cleanupJob(jobId, tempId);
showToast(t('generationTimedOut'), 'error');
}
}, 600000);
}, [token, cleanupJob, refreshSongsList]);
const buildTempSongFromParams = (params: GenerationParams, tempId: string, createdAt?: string) => ({
id: tempId,
title: params.title || 'Generating...',
lyrics: '',
style: params.style || params.songDescription || '',
coverUrl: 'https://picsum.photos/200/200?blur=10',
duration: '--:--',
createdAt: createdAt ? new Date(createdAt) : new Date(),
isGenerating: true,
tags: params.customMode ? ['custom'] : ['simple'],
isPublic: true,
});
// Handlers
const handleGenerate = async (params: GenerationParams) => {
if (!isAuthenticated || !token) {
setShowUsernameModal(true);
return;
}
setIsGenerating(true);
setCurrentView('create');
setMobileShowList(false);
// Create unique temp ID for this job
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const tempSong: Song = {
id: tempId,
title: params.title || 'Generating...',
lyrics: '',
style: params.style,
coverUrl: 'https://picsum.photos/200/200?blur=10',
duration: '--:--',
createdAt: new Date(),
isGenerating: true,
tags: params.customMode ? ['custom'] : ['simple'],
isPublic: true
};
setSongs(prev => [tempSong, ...prev]);
setSelectedSong(tempSong);
setShowRightSidebar(true);
try {
const job = await generateApi.startGeneration({
customMode: params.customMode,
songDescription: params.songDescription,
lyrics: params.lyrics,
style: params.style,
title: params.title,
instrumental: params.instrumental,
vocalLanguage: params.vocalLanguage,
duration: params.duration && params.duration > 0 ? params.duration : undefined,
bpm: params.bpm,
keyScale: params.keyScale,
timeSignature: params.timeSignature,
inferenceSteps: params.inferenceSteps,
guidanceScale: params.guidanceScale,
batchSize: params.batchSize,
randomSeed: params.randomSeed,
seed: params.seed,
thinking: params.thinking,
audioFormat: params.audioFormat,
inferMethod: params.inferMethod,
shift: params.shift,
lmTemperature: params.lmTemperature,
lmCfgScale: params.lmCfgScale,
lmTopK: params.lmTopK,
lmTopP: params.lmTopP,
lmNegativePrompt: params.lmNegativePrompt,
lmBackend: params.lmBackend,
lmModel: params.lmModel,
referenceAudioUrl: params.referenceAudioUrl,
sourceAudioUrl: params.sourceAudioUrl,
referenceAudioTitle: params.referenceAudioTitle,
sourceAudioTitle: params.sourceAudioTitle,
audioCodes: params.audioCodes,
repaintingStart: params.repaintingStart,
repaintingEnd: params.repaintingEnd,
instruction: params.instruction,
audioCoverStrength: params.audioCoverStrength,
taskType: params.taskType,
useAdg: params.useAdg,
cfgIntervalStart: params.cfgIntervalStart,
cfgIntervalEnd: params.cfgIntervalEnd,
customTimesteps: params.customTimesteps,
useCotMetas: params.useCotMetas,
useCotCaption: params.useCotCaption,
useCotLanguage: params.useCotLanguage,
autogen: params.autogen,
constrainedDecodingDebug: params.constrainedDecodingDebug,
allowLmBatch: params.allowLmBatch,
getScores: params.getScores,
getLrc: params.getLrc,
scoreScale: params.scoreScale,
lmBatchChunkSize: params.lmBatchChunkSize,
trackName: params.trackName,
completeTrackClasses: params.completeTrackClasses,
isFormatCaption: params.isFormatCaption,
}, token);
beginPollingJob(job.jobId, tempId);
} catch (e) {
console.error('Generation error:', e);
setSongs(prev => prev.filter(s => s.id !== tempId));
// Only set isGenerating to false if no other jobs are running
if (activeJobsRef.current.size === 0) {
setIsGenerating(false);
}
showToast(t('generationFailed'), 'error');
}
};
// Resume active jobs on refresh so progress keeps updating
useEffect(() => {
if (!isAuthenticated || !token) return;
const resumeJobs = async () => {
try {
const history = await generateApi.getHistory(token);
const jobs = Array.isArray(history.jobs) ? history.jobs : [];
const activeStatuses = new Set(['pending', 'queued', 'running']);
const jobsToResume = jobs.filter((job: any) => activeStatuses.has(job.status));
if (jobsToResume.length === 0) return;
setSongs(prev => {
const existingIds = new Set(prev.map(s => s.id));
const next = [...prev];
for (const job of jobsToResume) {
const jobId = job.id || job.jobId;
if (!jobId) continue;
const tempId = `job_${jobId}`;
if (existingIds.has(tempId)) continue;
const params = (() => {
try {
if (!job.params) return {};
return typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
} catch {
return {};
}
})();
next.unshift(buildTempSongFromParams(params, tempId, job.created_at));
existingIds.add(tempId);
}
return next;
});
for (const job of jobsToResume) {
const jobId = job.id || job.jobId;
if (!jobId) continue;
const tempId = `job_${jobId}`;
beginPollingJob(jobId, tempId);
}
} catch (error) {
console.error('Failed to resume jobs:', error);
}
};
resumeJobs();
}, [isAuthenticated, token, beginPollingJob]);
const togglePlay = () => {
if (!currentSong) return;
setIsPlaying(!isPlaying);
};
const playSong = (song: Song, list?: Song[]) => {
const nextQueue = list && list.length > 0
? list
: (playQueue.length > 0 && playQueue.some(s => s.id === song.id))
? playQueue
: (songs.some(s => s.id === song.id) ? songs : [song]);
const nextIndex = nextQueue.findIndex(s => s.id === song.id);
setPlayQueue(nextQueue);
setQueueIndex(nextIndex);
if (currentSong?.id !== song.id) {
const updatedSong = { ...song, viewCount: (song.viewCount || 0) + 1 };
setCurrentSong(updatedSong);
setSelectedSong(updatedSong);
setIsPlaying(true);
setSongs(prev => prev.map(s => s.id === song.id ? updatedSong : s));
songsApi.trackPlay(song.id, token).catch(err => console.error('Failed to track play:', err));
} else {
togglePlay();
}
if (currentSong?.id === song.id) {
setSelectedSong(song);
}
setShowRightSidebar(true);
};
const handleSeek = (time: number) => {
const audio = audioRef.current;
if (!audio) return;
if (Number.isNaN(audio.duration) || audio.readyState < 1 || audio.seekable.length === 0) {
pendingSeekRef.current = time;
return;
}
audio.currentTime = time;
setCurrentTime(time);
};
const toggleLike = async (songId: string) => {
if (!token) return;
const isLiked = likedSongIds.has(songId);
// Optimistic update
setLikedSongIds(prev => {
const next = new Set(prev);
if (isLiked) next.delete(songId);
else next.add(songId);
return next;
});
setSongs(prev => prev.map(s => {
if (s.id === songId) {
const newCount = (s.likeCount || 0) + (isLiked ? -1 : 1);
return { ...s, likeCount: Math.max(0, newCount) };
}
return s;
}));
if (selectedSong?.id === songId) {
setSelectedSong(prev => prev ? {
...prev,
likeCount: Math.max(0, (prev.likeCount || 0) + (isLiked ? -1 : 1))
} : null);
}
// Persist to database
try {
await songsApi.toggleLike(songId, token);
} catch (error) {
console.error('Failed to toggle like:', error);
// Revert on error
setLikedSongIds(prev => {
const next = new Set(prev);
if (isLiked) next.add(songId);
else next.delete(songId);
return next;
});
}
};
const handleDeleteSong = (song: Song) => {
handleDeleteSongs([song]);
};
const handleDeleteSongs = (songsToDelete: Song[]) => {
if (!token || songsToDelete.length === 0) return;
const isSingle = songsToDelete.length === 1;
const title = isSingle ? t('confirmDeleteTitle') : t('confirmDeleteManyTitle');
const message = isSingle
? t('deleteSongConfirm').replace('{title}', songsToDelete[0].title)
: t('deleteSongsConfirm').replace('{count}', String(songsToDelete.length));
setConfirmDialog({
title,
message,
onConfirm: async () => {
setConfirmDialog(null);
const idsToDelete = new Set(songsToDelete.map(song => song.id));
const succeeded: string[] = [];
const failed: string[] = [];
for (const song of songsToDelete) {
try {
await songsApi.deleteSong(song.id, token!);
succeeded.push(song.id);
} catch (error) {
console.error('Failed to delete song:', error);
failed.push(song.id);
}
}
if (succeeded.length > 0) {
setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
setLikedSongIds(prev => {
const next = new Set(prev);
succeeded.forEach(id => next.delete(id));
return next;
});
if (selectedSong?.id && succeeded.includes(selectedSong.id)) {
setSelectedSong(null);
}
if (currentSong?.id && succeeded.includes(currentSong.id)) {
setCurrentSong(null);
setIsPlaying(false);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}
setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
}
if (failed.length > 0) {
showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error');
} else if (isSingle) {
showToast(t('songDeleted'));
} else {
showToast(t('songsDeletedSuccess'));
}
},
});
};
const handleDeleteReferenceTrack = (trackId: string) => {
if (!token) return;
setConfirmDialog({
title: t('delete'),
message: t('deleteUploadConfirm'),
onConfirm: async () => {
setConfirmDialog(null);
try {
const response = await fetch(`/api/reference-tracks/${trackId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token!}` }
});
if (!response.ok) {
throw new Error('Failed to delete upload');
}
setReferenceTracks(prev => prev.filter(track => track.id !== trackId));
showToast(t('songDeleted'));
} catch (error) {
console.error('Failed to delete upload:', error);
showToast(t('failedToDeleteSong'), 'error');
}
},
});
};
const createPlaylist = async (name: string, description: string) => {
if (!token) return;
try {
const res = await playlistsApi.create(name, description, true, token);
setPlaylists(prev => [res.playlist, ...prev]);
if (songToAddToPlaylist) {
await playlistsApi.addSong(res.playlist.id, songToAddToPlaylist.id, token);
setSongToAddToPlaylist(null);
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
}
showToast(t('playlistCreated'));
} catch (error) {
console.error('Create playlist error:', error);
showToast(t('failedToCreatePlaylist'), 'error');
}
};
const openAddToPlaylistModal = (song: Song) => {
setSongToAddToPlaylist(song);
setIsAddToPlaylistModalOpen(true);
};
const addSongToPlaylist = async (playlistId: string) => {
if (!songToAddToPlaylist || !token) return;
try {
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
setSongToAddToPlaylist(null);
showToast(t('songAddedToPlaylist'));
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
} catch (error) {
console.error('Add song error:', error);
showToast(t('failedToAddSong'), 'error');
}
};
const handleNavigateToPlaylist = (playlistId: string) => {
setViewingPlaylistId(playlistId);
setCurrentView('playlist');
window.history.pushState({}, '', `/playlist/${playlistId}`);
};
const handleUseAsReference = (song: Song) => {
if (!song.audioUrl) return;
setPendingAudioSelection({ target: 'reference', url: song.audioUrl, title: song.title });
setCurrentView('create');
setMobileShowList(false);
};
const handleCoverSong = (song: Song) => {
if (!song.audioUrl) return;
setPendingAudioSelection({ target: 'source', url: song.audioUrl, title: song.title });
setCurrentView('create');
setMobileShowList(false);
};
const handleUseUploadAsReference = (track: { audio_url: string; filename: string }) => {
setPendingAudioSelection({
target: 'reference',
url: track.audio_url,
title: track.filename.replace(/\.[^/.]+$/, ''),
});
setCurrentView('create');
setMobileShowList(false);
};
const handleCoverUpload = (track: { audio_url: string; filename: string }) => {
setPendingAudioSelection({
target: 'source',
url: track.audio_url,
title: track.filename.replace(/\.[^/.]+$/, ''),
});
setCurrentView('create');
setMobileShowList(false);
};
const handleBackFromPlaylist = () => {
setViewingPlaylistId(null);
setCurrentView('library');
window.history.pushState({}, '', '/library');
};
const openVideoGenerator = (song: Song) => {
if (isPlaying) {
setIsPlaying(false);
if (audioRef.current) audioRef.current.pause();
}
setSongForVideo(song);
setIsVideoModalOpen(true);
};
// Handle username setup
const handleUsernameSubmit = async (username: string) => {
await setupUser(username);
setShowUsernameModal(false);
};
// Render Layout Logic
const renderContent = () => {
switch (currentView) {
case 'library': {
const allSongs = user ? songs.filter(s => s.userId === user.id) : [];
return (
<LibraryView
allSongs={allSongs}
likedSongs={songs.filter(s => likedSongIds.has(s.id))}
playlists={playlists}
referenceTracks={referenceTracks}
onPlaySong={playSong}
onCreatePlaylist={() => {
setSongToAddToPlaylist(null);
setIsCreatePlaylistModalOpen(true);
}}
onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)}
onAddToPlaylist={openAddToPlaylistModal}
onOpenVideo={openVideoGenerator}
onReusePrompt={handleReuse}
onDeleteSong={handleDeleteSong}
onDeleteReferenceTrack={handleDeleteReferenceTrack}
/>
);
}
case 'profile':
if (!viewingUsername) return null;
return (
<UserProfile
username={viewingUsername}
onBack={handleBackFromProfile}
onPlaySong={playSong}
onNavigateToProfile={handleNavigateToProfile}
onNavigateToPlaylist={handleNavigateToPlaylist}
currentSong={currentSong}
isPlaying={isPlaying}
likedSongIds={likedSongIds}
onToggleLike={toggleLike}
/>
);
case 'playlist':
if (!viewingPlaylistId) return null;
return (
<PlaylistDetail
playlistId={viewingPlaylistId}
onBack={handleBackFromPlaylist}
onPlaySong={playSong}
onSelect={(s) => {
setSelectedSong(s);
setShowRightSidebar(true);
}}
onNavigateToProfile={handleNavigateToProfile}
/>
);
case 'song':
if (!viewingSongId) return null;
return (
<SongProfile
songId={viewingSongId}
onBack={handleBackFromSong}
onPlay={playSong}
onNavigateToProfile={handleNavigateToProfile}
currentSong={currentSong}
isPlaying={isPlaying}
likedSongIds={likedSongIds}
onToggleLike={toggleLike}
/>
);
case 'search':
return (
<SearchPage
onPlaySong={playSong}
currentSong={currentSong}
isPlaying={isPlaying}
onNavigateToProfile={handleNavigateToProfile}
onNavigateToSong={handleNavigateToSong}
onNavigateToPlaylist={handleNavigateToPlaylist}
/>
);
case 'create':
default:
return (
<div className="flex h-full overflow-hidden relative w-full">
{/* Create Panel */}
<div className={`
${mobileShowList ? 'hidden md:block' : 'w-full'}
md:w-[320px] lg:w-[360px] flex-shrink-0 h-full border-r border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-suno-panel relative z-10 transition-colors duration-300
`}>
<CreatePanel
onGenerate={handleGenerate}
isGenerating={isGenerating}
initialData={reuseData}
createdSongs={songs}
pendingAudioSelection={pendingAudioSelection}
onAudioSelectionApplied={() => setPendingAudioSelection(null)}
/>
</div>
{/* Song List */}
<div className={`
${!mobileShowList ? 'hidden md:flex' : 'flex'}
flex-1 flex-col h-full overflow-hidden bg-white dark:bg-suno-DEFAULT transition-colors duration-300
`}>
<SongList
songs={songs}
currentSong={currentSong}
selectedSong={selectedSong}
likedSongIds={likedSongIds}
isPlaying={isPlaying}
referenceTracks={referenceTracks}
onPlay={playSong}
onSelect={(s) => {
setSelectedSong(s);
setShowRightSidebar(true);
}}
onToggleLike={toggleLike}
onAddToPlaylist={openAddToPlaylistModal}
onOpenVideo={openVideoGenerator}
onShowDetails={handleShowDetails}
onNavigateToProfile={handleNavigateToProfile}
onReusePrompt={handleReuse}
onDelete={handleDeleteSong}
onDeleteMany={handleDeleteSongs}
onUseAsReference={handleUseAsReference}
onCoverSong={handleCoverSong}
onUseUploadAsReference={handleUseUploadAsReference}
onCoverUpload={handleCoverUpload}
onSongUpdate={handleSongUpdate}
/>
</div>
{/* Right Sidebar */}
{showRightSidebar && (
<div className="hidden xl:block w-[360px] flex-shrink-0 h-full bg-zinc-50 dark:bg-suno-panel relative z-10 border-l border-zinc-200 dark:border-white/5 transition-colors duration-300">
<RightSidebar
song={selectedSong}
onClose={() => setShowRightSidebar(false)}
onOpenVideo={() => selectedSong && openVideoGenerator(selectedSong)}
onReuse={handleReuse}
onSongUpdate={handleSongUpdate}
onNavigateToProfile={handleNavigateToProfile}
onNavigateToSong={handleNavigateToSong}
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike}
onDelete={handleDeleteSong}
/>
</div>
)}
{/* Mobile Toggle Button */}
<div className="md:hidden absolute top-4 right-4 z-50">
<button
onClick={() => setMobileShowList(!mobileShowList)}
className="bg-zinc-800 text-white px-4 py-2 rounded-full shadow-lg border border-white/10 flex items-center gap-2 text-sm font-bold"
>
{mobileShowList ? t('createSong') : t('viewList')}
<List size={16} />
</button>
</div>
</div>
);
}
};
return (
<div className="flex flex-col h-screen bg-white dark:bg-suno-DEFAULT text-zinc-900 dark:text-white font-sans antialiased selection:bg-pink-500/30 transition-colors duration-300">
<div className="flex-1 flex overflow-hidden">
<Sidebar
currentView={currentView}
onNavigate={(v) => {
setCurrentView(v);
if (v === 'create') {
setMobileShowList(false);
window.history.pushState({}, '', '/');
} else if (v === 'library') {
window.history.pushState({}, '', '/library');
} else if (v === 'search') {
window.history.pushState({}, '', '/search');
}
if (isMobile) setShowLeftSidebar(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
user={user}
onLogin={() => setShowUsernameModal(true)}
onLogout={logout}
onOpenSettings={() => setShowSettingsModal(true)}
isOpen={showLeftSidebar}
onToggle={() => setShowLeftSidebar(!showLeftSidebar)}
/>
<main className="flex-1 flex overflow-hidden relative">
{renderContent()}
</main>
</div>
<Player
currentSong={currentSong}
isPlaying={isPlaying}
onTogglePlay={togglePlay}
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
onNext={playNext}
onPrevious={playPrevious}
volume={volume}
onVolumeChange={setVolume}
playbackRate={playbackRate}
onPlaybackRateChange={setPlaybackRate}
audioRef={audioRef}
isShuffle={isShuffle}
onToggleShuffle={() => setIsShuffle(!isShuffle)}
repeatMode={repeatMode}
onToggleRepeat={() => setRepeatMode(prev => prev === 'none' ? 'all' : prev === 'all' ? 'one' : 'none')}
isLiked={currentSong ? likedSongIds.has(currentSong.id) : false}
onToggleLike={() => currentSong && toggleLike(currentSong.id)}
onNavigateToSong={handleNavigateToSong}
onOpenVideo={() => currentSong && openVideoGenerator(currentSong)}
onReusePrompt={() => currentSong && handleReuse(currentSong)}
onAddToPlaylist={() => currentSong && openAddToPlaylistModal(currentSong)}
onDelete={() => currentSong && handleDeleteSong(currentSong)}
/>
<CreatePlaylistModal
isOpen={isCreatePlaylistModalOpen}
onClose={() => setIsCreatePlaylistModalOpen(false)}
onCreate={createPlaylist}
/>
<AddToPlaylistModal
isOpen={isAddToPlaylistModalOpen}
onClose={() => setIsAddToPlaylistModalOpen(false)}
playlists={playlists}
onSelect={addSongToPlaylist}
onCreateNew={() => {
setIsAddToPlaylistModalOpen(false);
setIsCreatePlaylistModalOpen(true);
}}
/>
<Toast
message={toast.message}
type={toast.type}
isVisible={toast.isVisible}
onClose={closeToast}
/>
<VideoGeneratorModal
isOpen={isVideoModalOpen}
onClose={() => setIsVideoModalOpen(false)}
song={songForVideo}
/>
<UsernameModal
isOpen={showUsernameModal}
onSubmit={handleUsernameSubmit}
/>
<SettingsModal
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
theme={theme}
onToggleTheme={toggleTheme}
onNavigateToProfile={handleNavigateToProfile}
/>
{/* Mobile Details Modal */}
{showMobileDetails && selectedSong && (
<div className="fixed inset-0 z-[60] flex justify-end xl:hidden">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in"
onClick={() => setShowMobileDetails(false)}
/>
<div className="relative w-full max-w-md h-full bg-zinc-50 dark:bg-suno-panel shadow-2xl animate-in slide-in-from-right duration-300 border-l border-white/10">
<RightSidebar
song={selectedSong}
onClose={() => setShowMobileDetails(false)}
onOpenVideo={() => selectedSong && openVideoGenerator(selectedSong)}
onReuse={handleReuse}
onSongUpdate={handleSongUpdate}
onNavigateToProfile={handleNavigateToProfile}
onNavigateToSong={handleNavigateToSong}
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike}
onDelete={handleDeleteSong}
/>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmDialog !== null}
title={confirmDialog?.title ?? ''}
message={confirmDialog?.message ?? ''}
onConfirm={() => confirmDialog?.onConfirm()}
onCancel={() => setConfirmDialog(null)}
/>
</div>
);
}
export default function App() {
return (
<I18nProvider>
<AppContent />
</I18nProvider>
);
}