diff --git a/src/app/api/music/v2/lyric/route.ts b/src/app/api/music/v2/lyric/route.ts index c779a67..34664ff 100644 --- a/src/app/api/music/v2/lyric/route.ts +++ b/src/app/api/music/v2/lyric/route.ts @@ -15,7 +15,14 @@ export async function POST(request: NextRequest) { const data = await fetchLxLyric(song); - return NextResponse.json({ success: true, data: { lyric: data.lyric || '', tlyric: data.tlyric || '' } }); + return NextResponse.json( + { success: true, data: { lyric: data.lyric || '', tlyric: data.tlyric || '' } }, + { + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + } + ); } catch (error) { console.error('[music-v2] lyric route error:', error); return internalError('获取歌词失败', (error as Error).message); diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index 96da4df..4d9bb62 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -11,7 +11,7 @@ import MusicSidebarDrawer from '@/components/music/MusicSidebarDrawer'; import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider'; import { getSourceDisplayLabel, normalizeSource, SourcePill } from '@/lib/music/shared'; import type { MusicQuality, MusicSource, Song } from '@/lib/music/types'; -import type { MusicQueueItem, MusicState } from '@/types/watch-room'; +import type { MusicQueueItem, MusicSyncState } from '@/types/watch-room'; const SPECTRUM_BIN_COUNT = 96; const SPECTRUM_IDLE_LEVEL = 0.02; @@ -50,6 +50,10 @@ interface DbRecord { songmid?: string; } +function getMusicQueueItemKey(song: { id: string; platform?: string }, fallbackPlatform = '') { + return `${song.platform || fallbackPlatform}:${song.id}`; +} + function AudioSpectrumCanvas({ bars, compact = false, @@ -305,7 +309,8 @@ export default function MusicClient({ children: _children }: { children?: React. const qualitySwitchRequestRef = useRef(0); const currentSongRef = useRef(null); const currentSourceRef = useRef(currentSource); - const lastMusicQueueSignatureRef = useRef(''); + const plannedNextSongRef = useRef(null); + const plannedNextSongKeyRef = useRef(''); const isMusicRoomOwner = Boolean( watchRoom?.isOwner && @@ -327,28 +332,57 @@ export default function MusicClient({ children: _children }: { children?: React. const buildMusicRoomState = ( song: Song, options: { - queue?: Song[]; - currentIndex?: number; currentTime?: number; isPlaying?: boolean; + nextSong?: Song | null; } = {} - ): MusicState => { + ): MusicSyncState => { const songPlatform = song.platform || currentSourceRef.current; - let queue = options.queue && options.queue.length > 0 ? options.queue : playlist; - let currentIndex = options.currentIndex ?? queue.findIndex((item) => item.id === song.id && (item.platform || currentSourceRef.current) === songPlatform); + const currentSong = { ...song, platform: songPlatform }; + const playlistSnapshot = playlist + .map((item) => ({ ...item, platform: item.platform || songPlatform })) + .filter((item) => item.id); + const currentKey = getMusicQueueItemKey(currentSong, songPlatform); + const playlistKey = playlistSnapshot.map((item) => getMusicQueueItemKey(item)).join('|'); + const cacheKey = `${currentKey}|${playMode}|${playlistKey}`; - if (currentIndex < 0) { - queue = [...queue, { ...song, platform: songPlatform }]; - currentIndex = queue.length - 1; - } + const resolveNextSong = (): Song | null => { + if (typeof options.nextSong !== 'undefined') return options.nextSong || null; + if (plannedNextSongKeyRef.current === cacheKey) { + return plannedNextSongRef.current; + } - const currentQueueSong = { ...queue[currentIndex], platform: queue[currentIndex].platform || songPlatform }; + let currentIndex = playlistSnapshot.findIndex((item) => getMusicQueueItemKey(item) === currentKey); + if (currentIndex < 0) { + playlistSnapshot.push(currentSong); + currentIndex = playlistSnapshot.length - 1; + } + let nextSong: Song | null = null; + if (playMode === 'single') { + nextSong = playlistSnapshot[currentIndex] || null; + } else if (playMode === 'random') { + if (playlistSnapshot.length === 1) { + nextSong = playlistSnapshot[0] || null; + } else { + const candidates = playlistSnapshot.filter((_, index) => index !== currentIndex); + nextSong = candidates[Math.floor(Math.random() * candidates.length)] || null; + } + } else { + const nextIndex = currentIndex < playlistSnapshot.length - 1 ? currentIndex + 1 : 0; + nextSong = playlistSnapshot[nextIndex] || null; + } + + plannedNextSongKeyRef.current = cacheKey; + plannedNextSongRef.current = nextSong; + return nextSong; + }; + + const nextSong = resolveNextSong(); return { type: 'music', - queue: queue.map(toMusicQueueItem), - currentIndex, - song: toMusicQueueItem(currentQueueSong), + song: toMusicQueueItem(currentSong), + nextSong: nextSong ? toMusicQueueItem(nextSong) : null, currentTime: options.currentTime ?? audioRef.current?.currentTime ?? currentTimeRef.current ?? 0, isPlaying: options.isPlaying ?? isPlaying, quality, @@ -357,14 +391,9 @@ export default function MusicClient({ children: _children }: { children?: React. }; }; - const emitMusicChange = (song: Song | null, nextQueue?: Song[], nextIndex?: number, playing = true) => { - if (!isMusicRoomOwner || !watchRoom || !song) return; - watchRoom.changeMusic(buildMusicRoomState(song, { - queue: nextQueue, - currentIndex: nextIndex, - currentTime: audioRef.current?.currentTime || 0, - isPlaying: playing, - })); + const emitMusicChange = (state: MusicSyncState | null) => { + if (!isMusicRoomOwner || !watchRoom || !state) return; + watchRoom.changeMusic(state); }; const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => { @@ -697,17 +726,7 @@ export default function MusicClient({ children: _children }: { children?: React. useEffect(() => { if (!isMusicRoomOwner || !watchRoom || !currentSong) return; - const state = buildMusicRoomState(currentSong); - const signature = JSON.stringify({ - queue: state.queue.map((item) => `${item.platform}:${item.id}`), - currentIndex: state.currentIndex, - playMode: state.playMode, - quality: state.quality, - }); - - if (signature === lastMusicQueueSignatureRef.current) return; - lastMusicQueueSignatureRef.current = signature; - watchRoom.updateMusicQueue(state); + watchRoom.updateMusicState(buildMusicRoomState(currentSong)); }, [isMusicRoomOwner, watchRoom, currentSong, playlist, playlistIndex, playMode, quality]); useEffect(() => { @@ -819,9 +838,6 @@ export default function MusicClient({ children: _children }: { children?: React. const proxyEnabled = getMusicProxyEnabled(); setMusicProxyEnabled(proxyEnabled); const syncSong = { ...song, platform }; - const existingQueueIndex = playlist.findIndex(s => s.id === song.id && (s.platform || platform) === platform); - const syncQueue = existingQueueIndex >= 0 ? playlist : [...playlist, syncSong]; - const syncIndex = existingQueueIndex >= 0 ? existingQueueIndex : syncQueue.length - 1; // 记录歌曲开始播放的时间 songStartTimeRef.current = Date.now(); @@ -872,7 +888,10 @@ export default function MusicClient({ children: _children }: { children?: React. }); saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0); - emitMusicChange(syncSong, syncQueue, syncIndex, true); + emitMusicChange(buildMusicRoomState(syncSong, { + currentTime: 0, + isPlaying: true, + })); if (proxyEnabled) { const streamUrl = buildStreamUrl(song, platform, quality); diff --git a/src/app/watch-room/music/page.tsx b/src/app/watch-room/music/page.tsx index 0556cc9..a39ca6c 100644 --- a/src/app/watch-room/music/page.tsx +++ b/src/app/watch-room/music/page.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; -import type { MusicQueueItem, MusicState } from '@/types/watch-room'; +import type { MusicQueueItem, MusicSyncState } from '@/types/watch-room'; interface LyricLine { time: number; @@ -73,7 +73,7 @@ function parseLyric(lyricText = '', tlyricText = ''): LyricLine[] { })).filter((line) => line.text || line.translation); } -function adjustedTime(state: Pick, playing: boolean) { +function adjustedTime(state: Pick, playing: boolean) { if (!playing) return state.currentTime; return Math.max(0, state.currentTime + (Date.now() - state.updatedAt) / 1000); } @@ -232,9 +232,12 @@ export default function WatchRoomMusicPage() { const volumeRef = useRef(100); const playbackRequestIdRef = useRef(0); const lyricRequestIdRef = useRef(0); - const lyricsContainerRef = useRef(null); + const mobileLyricsContainerRef = useRef(null); + const desktopLyricsContainerRef = useRef(null); + const mobileVolumeControlRef = useRef(null); + const desktopVolumeControlRef = useRef(null); - const [state, setState] = useState(() => ( + const [state, setState] = useState(() => ( currentRoom?.currentState?.type === 'music' ? currentRoom.currentState : null )); const [lyrics, setLyrics] = useState([]); @@ -283,7 +286,7 @@ export default function WatchRoomMusicPage() { useEffect(() => { if (currentLyricIndex < 0) return; - const container = lyricsContainerRef.current; + const container = window.innerWidth < 768 ? mobileLyricsContainerRef.current : desktopLyricsContainerRef.current; if (!container) return; const active = container.querySelector(`[data-lyric-index="${currentLyricIndex}"]`); if (!active) return; @@ -311,6 +314,22 @@ export default function WatchRoomMusicPage() { return () => observer.disconnect(); }, []); + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!showVolumeSlider) return; + const target = event.target as Node | null; + if (!target) return; + const insideMobile = mobileVolumeControlRef.current?.contains(target) ?? false; + const insideDesktop = desktopVolumeControlRef.current?.contains(target) ?? false; + if (!insideMobile && !insideDesktop) { + setShowVolumeSlider(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [showVolumeSlider]); + useEffect(() => { volumeRef.current = volume; if (audioRef.current) { @@ -337,7 +356,7 @@ export default function WatchRoomMusicPage() { if (audioContextRef.current.state === 'suspended') await audioContextRef.current.resume(); }; - const applyPlaybackState = async (nextState: MusicState) => { + const applyPlaybackState = async (nextState: MusicSyncState) => { const audio = audioRef.current; if (!audio) return; const requestId = ++playbackRequestIdRef.current; @@ -415,7 +434,7 @@ export default function WatchRoomMusicPage() { useEffect(() => { if (!socket) return; - const handleState = (nextState: MusicState) => { + const handleState = (nextState: MusicSyncState) => { setState(nextState); setCurrentTime(adjustedTime(nextState, nextState.isPlaying)); if (Number.isFinite(nextState.song.duration) && nextState.song.duration) { @@ -491,7 +510,7 @@ export default function WatchRoomMusicPage() { const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0; const song = state?.song; - const nextSong = state && state.queue.length > 1 ? state.queue[(state.currentIndex + 1) % state.queue.length] : null; + const nextSong = state?.nextSong || null; const themeRootClass = isDark ? 'bg-zinc-950 text-white' : 'bg-white text-zinc-900'; const showCoverPanel = mobilePanel === 'cover'; const showLyricsPanel = mobilePanel === 'lyrics'; @@ -530,26 +549,117 @@ export default function WatchRoomMusicPage() { {song ? ( <> -
- - +
+
+
+
+ + +
+
+ + {showCoverPanel ? ( +
+
+ + +
+ {formatTime(currentTime)} +
+
+
+ {formatTime(duration)} +
+
+
+ ) : ( +
+
+ {lyrics.length > 0 ? ( +
+ {lyrics.map((line, index) => ( +
+
{line.text || '♪'}
+ {line.translation &&
{line.translation}
} +
+ ))} +
+ ) : ( +
暂无歌词
+ )} +
+
+ +
+
+ {formatTime(currentTime)} +
+
+
+ {formatTime(duration)} +
+
+ )} +
-
+
-
+