Fix player: clickable empty state, skip non-playable songs, spacebar shortcut
- Make bottom player empty state a clickable button that plays first available song - Guard togglePlay to show toast when audioUrl is missing instead of silently failing - playNext/playPrevious now skip songs without audioUrl or still generating - repeatMode 'none' stops at queue boundaries instead of wrapping - Add ShareModal to mobile fullscreen player (was missing, share silently failed) - Add spacebar play/pause keyboard shortcut (skips when typing in inputs) - Add onPlayFirst prop to Player component - Add selectSongToPlay i18n key (en, zh, ja, ko)
This commit is contained in:
@@ -408,19 +408,34 @@ function AppContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find next playable song (has audioUrl and not generating)
|
||||
const queueLen = queue.length;
|
||||
for (let i = 1; i <= queueLen; i++) {
|
||||
let nextIndex;
|
||||
if (isShuffle) {
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
} while (queue.length > 1 && nextIndex === currentIndex);
|
||||
nextIndex = Math.floor(Math.random() * queueLen);
|
||||
if (queueLen > 1 && nextIndex === currentIndex) continue;
|
||||
} else {
|
||||
nextIndex = (currentIndex + 1) % queue.length;
|
||||
nextIndex = currentIndex + i;
|
||||
// In 'none' repeat mode, stop at end of queue
|
||||
if (repeatMode === 'none' && nextIndex >= queueLen) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
nextIndex = nextIndex % queueLen;
|
||||
}
|
||||
|
||||
const nextSong = queue[nextIndex];
|
||||
const candidate = queue[nextIndex];
|
||||
if (candidate.audioUrl && !candidate.isGenerating) {
|
||||
setQueueIndex(nextIndex);
|
||||
setCurrentSong(nextSong);
|
||||
setCurrentSong(candidate);
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No playable songs found
|
||||
setIsPlaying(false);
|
||||
}, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs]);
|
||||
|
||||
const playPrevious = useCallback(() => {
|
||||
@@ -438,16 +453,35 @@ function AppContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevIndex = (currentIndex - 1 + queue.length) % queue.length;
|
||||
// Find previous playable song (has audioUrl and not generating)
|
||||
const queueLen = queue.length;
|
||||
for (let i = 1; i <= queueLen; i++) {
|
||||
let prevIndex;
|
||||
if (isShuffle) {
|
||||
prevIndex = Math.floor(Math.random() * queue.length);
|
||||
prevIndex = Math.floor(Math.random() * queueLen);
|
||||
if (queueLen > 1 && prevIndex === currentIndex) continue;
|
||||
} else {
|
||||
prevIndex = currentIndex - i;
|
||||
// In 'none' repeat mode, stop at beginning of queue
|
||||
if (repeatMode === 'none' && prevIndex < 0) {
|
||||
if (audioRef.current) audioRef.current.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
prevIndex = (prevIndex + queueLen) % queueLen;
|
||||
}
|
||||
|
||||
const prevSong = queue[prevIndex];
|
||||
const candidate = queue[prevIndex];
|
||||
if (candidate.audioUrl && !candidate.isGenerating) {
|
||||
setQueueIndex(prevIndex);
|
||||
setCurrentSong(prevSong);
|
||||
setCurrentSong(candidate);
|
||||
setIsPlaying(true);
|
||||
}, [currentSong, queueIndex, currentTime, isShuffle, playQueue, songs]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No playable songs found
|
||||
setIsPlaying(false);
|
||||
}, [currentSong, queueIndex, currentTime, isShuffle, repeatMode, playQueue, songs]);
|
||||
|
||||
useEffect(() => {
|
||||
playNextRef.current = playNext;
|
||||
@@ -565,6 +599,29 @@ function AppContent() {
|
||||
}
|
||||
}, [playbackRate]);
|
||||
|
||||
// Spacebar play/pause
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
if (currentSong) {
|
||||
if (currentSong.audioUrl) {
|
||||
setIsPlaying(prev => !prev);
|
||||
}
|
||||
} else {
|
||||
// No song selected — play first available
|
||||
const available = songs.filter(s => s.audioUrl && !s.isGenerating);
|
||||
if (available.length > 0) {
|
||||
playSong(available[0], available);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentSong, songs]);
|
||||
|
||||
// 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);
|
||||
@@ -862,9 +919,20 @@ function AppContent() {
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!currentSong) return;
|
||||
if (!currentSong.audioUrl) {
|
||||
showToast(t('songNotAvailable'), 'error');
|
||||
return;
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const playFirst = () => {
|
||||
const available = songs.filter(s => s.audioUrl && !s.isGenerating);
|
||||
if (available.length > 0) {
|
||||
playSong(available[0], available);
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = (song: Song, list?: Song[]) => {
|
||||
const nextQueue = list && list.length > 0
|
||||
? list
|
||||
@@ -1293,6 +1361,9 @@ function AppContent() {
|
||||
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
|
||||
onToggleLike={toggleLike}
|
||||
onDelete={handleDeleteSong}
|
||||
onPlay={playSong}
|
||||
isPlaying={isPlaying}
|
||||
currentSong={currentSong}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1371,6 +1442,7 @@ function AppContent() {
|
||||
onReusePrompt={() => currentSong && handleReuse(currentSong)}
|
||||
onAddToPlaylist={() => currentSong && openAddToPlaylistModal(currentSong)}
|
||||
onDelete={() => currentSong && handleDeleteSong(currentSong)}
|
||||
onPlayFirst={playFirst}
|
||||
/>
|
||||
|
||||
<CreatePlaylistModal
|
||||
@@ -1430,6 +1502,9 @@ function AppContent() {
|
||||
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
|
||||
onToggleLike={toggleLike}
|
||||
onDelete={handleDeleteSong}
|
||||
onPlay={playSong}
|
||||
isPlaying={isPlaying}
|
||||
currentSong={currentSong}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-5
@@ -33,6 +33,7 @@ interface PlayerProps {
|
||||
onReusePrompt?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onDelete?: () => void;
|
||||
onPlayFirst?: () => void;
|
||||
}
|
||||
|
||||
export const Player: React.FC<PlayerProps> = ({
|
||||
@@ -59,7 +60,8 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
onOpenVideo,
|
||||
onReusePrompt,
|
||||
onAddToPlaylist,
|
||||
onDelete
|
||||
onDelete,
|
||||
onPlayFirst
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
@@ -100,12 +102,15 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
if (!currentSong) {
|
||||
return (
|
||||
<div className="h-20 lg:h-24 bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex items-center justify-center z-50 transition-colors duration-300 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] dark:shadow-none">
|
||||
<div className="flex items-center gap-3 text-zinc-400 dark:text-zinc-600">
|
||||
<button
|
||||
onClick={() => onPlayFirst?.()}
|
||||
className="flex items-center gap-3 text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 rounded bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<Play size={20} className="text-zinc-400 dark:text-zinc-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Select a song to play</span>
|
||||
<Play size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t('selectSongToPlay')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -321,6 +326,12 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={currentSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -474,6 +474,7 @@ export const translations = {
|
||||
|
||||
// Player
|
||||
nowPlaying: 'Now Playing',
|
||||
selectSongToPlay: 'Select a song to play',
|
||||
downloadAudio: 'Download Audio',
|
||||
openInEditor: 'Open in Editor',
|
||||
anonymous: 'Anonymous',
|
||||
@@ -1069,6 +1070,7 @@ export const translations = {
|
||||
|
||||
// Player
|
||||
nowPlaying: '正在播放',
|
||||
selectSongToPlay: '选择一首歌曲播放',
|
||||
downloadAudio: '下载音频',
|
||||
openInEditor: '在编辑器中打开',
|
||||
anonymous: '匿名用户',
|
||||
@@ -1664,6 +1666,7 @@ export const translations = {
|
||||
|
||||
// Player
|
||||
nowPlaying: '再生中',
|
||||
selectSongToPlay: '曲を選択して再生',
|
||||
downloadAudio: 'オーディオをダウンロード',
|
||||
openInEditor: 'エディターで開く',
|
||||
anonymous: '匿名ユーザー',
|
||||
@@ -2259,6 +2262,7 @@ export const translations = {
|
||||
|
||||
// Player
|
||||
nowPlaying: '재생 중',
|
||||
selectSongToPlay: '재생할 곡을 선택하세요',
|
||||
downloadAudio: '오디오 다운로드',
|
||||
openInEditor: '편집기에서 열기',
|
||||
anonymous: '익명 사용자',
|
||||
|
||||
Reference in New Issue
Block a user