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:
fspecii
2026-02-10 12:26:28 +02:00
parent 565faacb7b
commit ac99e9efcb
3 changed files with 118 additions and 28 deletions
+86 -11
View File
@@ -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
View File
@@ -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>
);
}
+4
View File
@@ -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: '익명 사용자',