From ac99e9efcbf88c5459101610061d5e3641137f54 Mon Sep 17 00:00:00 2001
From: fspecii <4722521+fspecii@users.noreply.github.com>
Date: Tue, 10 Feb 2026 12:26:28 +0200
Subject: [PATCH] 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)
---
App.tsx | 113 +++++++++++++++++++++++++++++++++++-------
components/Player.tsx | 21 ++++++--
i18n/translations.ts | 12 +++--
3 files changed, 118 insertions(+), 28 deletions(-)
diff --git a/App.tsx b/App.tsx
index 461623a..1a2bf0e 100644
--- a/App.tsx
+++ b/App.tsx
@@ -408,19 +408,34 @@ function AppContent() {
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;
+ // Find next playable song (has audioUrl and not generating)
+ const queueLen = queue.length;
+ for (let i = 1; i <= queueLen; i++) {
+ let nextIndex;
+ if (isShuffle) {
+ nextIndex = Math.floor(Math.random() * queueLen);
+ if (queueLen > 1 && nextIndex === currentIndex) continue;
+ } else {
+ nextIndex = currentIndex + i;
+ // In 'none' repeat mode, stop at end of queue
+ if (repeatMode === 'none' && nextIndex >= queueLen) {
+ setIsPlaying(false);
+ return;
+ }
+ nextIndex = nextIndex % queueLen;
+ }
+
+ const candidate = queue[nextIndex];
+ if (candidate.audioUrl && !candidate.isGenerating) {
+ setQueueIndex(nextIndex);
+ setCurrentSong(candidate);
+ setIsPlaying(true);
+ return;
+ }
}
- const nextSong = queue[nextIndex];
- setQueueIndex(nextIndex);
- setCurrentSong(nextSong);
- setIsPlaying(true);
+ // 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;
- if (isShuffle) {
- prevIndex = Math.floor(Math.random() * 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() * 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 candidate = queue[prevIndex];
+ if (candidate.audioUrl && !candidate.isGenerating) {
+ setQueueIndex(prevIndex);
+ setCurrentSong(candidate);
+ setIsPlaying(true);
+ return;
+ }
}
- const prevSong = queue[prevIndex];
- setQueueIndex(prevIndex);
- setCurrentSong(prevSong);
- setIsPlaying(true);
- }, [currentSong, queueIndex, currentTime, isShuffle, playQueue, songs]);
+ // 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}
/>
)}
@@ -1371,6 +1442,7 @@ function AppContent() {
onReusePrompt={() => currentSong && handleReuse(currentSong)}
onAddToPlaylist={() => currentSong && openAddToPlaylistModal(currentSong)}
onDelete={() => currentSong && handleDeleteSong(currentSong)}
+ onPlayFirst={playFirst}
/>
diff --git a/components/Player.tsx b/components/Player.tsx
index fb3985c..331abbb 100644
--- a/components/Player.tsx
+++ b/components/Player.tsx
@@ -33,6 +33,7 @@ interface PlayerProps {
onReusePrompt?: () => void;
onAddToPlaylist?: () => void;
onDelete?: () => void;
+ onPlayFirst?: () => void;
}
export const Player: React.FC = ({
@@ -59,7 +60,8 @@ export const Player: React.FC = ({
onOpenVideo,
onReusePrompt,
onAddToPlaylist,
- onDelete
+ onDelete,
+ onPlayFirst
}) => {
const { user } = useAuth();
const { isMobile } = useResponsive();
@@ -100,12 +102,15 @@ export const Player: React.FC = ({
if (!currentSong) {
return (
-
+
+
{t('selectSongToPlay')}
+
);
}
@@ -321,6 +326,12 @@ export const Player: React.FC = ({
/>
)}
+
+ setShareModalOpen(false)}
+ song={currentSong}
+ />
);
}
diff --git a/i18n/translations.ts b/i18n/translations.ts
index 87d5426..2e0e782 100644
--- a/i18n/translations.ts
+++ b/i18n/translations.ts
@@ -474,10 +474,11 @@ export const translations = {
// Player
nowPlaying: 'Now Playing',
+ selectSongToPlay: 'Select a song to play',
downloadAudio: 'Download Audio',
openInEditor: 'Open in Editor',
anonymous: 'Anonymous',
-
+
// PlaylistDetail
loadingPlaylist: 'Loading playlist...',
playlistNotFound: 'Playlist not found',
@@ -1069,10 +1070,11 @@ export const translations = {
// Player
nowPlaying: '正在播放',
+ selectSongToPlay: '选择一首歌曲播放',
downloadAudio: '下载音频',
openInEditor: '在编辑器中打开',
anonymous: '匿名用户',
-
+
// PlaylistDetail
loadingPlaylist: '加载播放列表中...',
playlistNotFound: '播放列表未找到',
@@ -1664,10 +1666,11 @@ export const translations = {
// Player
nowPlaying: '再生中',
+ selectSongToPlay: '曲を選択して再生',
downloadAudio: 'オーディオをダウンロード',
openInEditor: 'エディターで開く',
anonymous: '匿名ユーザー',
-
+
// PlaylistDetail
loadingPlaylist: 'プレイリストを読み込み中...',
playlistNotFound: 'プレイリストが見つかりません',
@@ -2259,10 +2262,11 @@ export const translations = {
// Player
nowPlaying: '재생 중',
+ selectSongToPlay: '재생할 곡을 선택하세요',
downloadAudio: '오디오 다운로드',
openInEditor: '편집기에서 열기',
anonymous: '익명 사용자',
-
+
// PlaylistDetail
loadingPlaylist: '재생목록 로딩 중...',
playlistNotFound: '재생목록을 찾을 수 없습니다',