diff --git a/server.js b/server.js index 6b5fc5b..ff21c5a 100644 --- a/server.js +++ b/server.js @@ -203,7 +203,7 @@ class WatchRoomServer { socket.on('play:update', (state) => { console.log(`[WatchRoom] Received play:update from ${socket.id}:`, state); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) { + if (!roomInfo || !roomInfo.isOwner) { console.log('[WatchRoom] No room info for socket, ignoring play:update'); return; } @@ -223,7 +223,7 @@ class WatchRoomServer { socket.on('play:seek', (currentTime) => { console.log(`[WatchRoom] Received play:seek from ${socket.id}:`, currentTime); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) { + if (!roomInfo || !roomInfo.isOwner) { console.log('[WatchRoom] No room info for socket, ignoring play:seek'); return; } @@ -235,7 +235,7 @@ class WatchRoomServer { socket.on('play:play', () => { console.log(`[WatchRoom] Received play:play from ${socket.id}`); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) { + if (!roomInfo || !roomInfo.isOwner) { console.log('[WatchRoom] No room info for socket, ignoring play:play'); return; } @@ -247,7 +247,7 @@ class WatchRoomServer { socket.on('play:pause', () => { console.log(`[WatchRoom] Received play:pause from ${socket.id}`); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) { + if (!roomInfo || !roomInfo.isOwner) { console.log('[WatchRoom] No room info for socket, ignoring play:pause'); return; } @@ -292,6 +292,78 @@ class WatchRoomServer { } }); + socket.on('music:change', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:change', state); + } + }); + + socket.on('music:update', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:update', state); + } + }); + + socket.on('music:queue', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:queue', state); + } + }); + + socket.on('music:play', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state, isPlaying: true }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:play', state); + } + }); + + socket.on('music:pause', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state, isPlaying: false }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:pause', state); + } + }); + + socket.on('music:seek', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:seek', state); + } + }); + socket.on('screen:helper-register', (data, callback) => { try { const room = this.rooms.get(data.roomId); diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index 459e785..96da4df 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -8,8 +8,10 @@ import AddToPlaylistModal from '@/components/AddToPlaylistModal'; import Toast, { ToastProps } from '@/components/Toast'; import LyricsPiPWindow from '@/components/LyricsPiPWindow'; 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'; const SPECTRUM_BIN_COUNT = 96; const SPECTRUM_IDLE_LEVEL = 0.02; @@ -213,6 +215,7 @@ declare global { export default function MusicClient({ children: _children }: { children?: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); + const watchRoom = useWatchRoomContextSafe(); const [currentSource, setCurrentSource] = useState('wy'); const [currentSong, setCurrentSong] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -302,6 +305,67 @@ export default function MusicClient({ children: _children }: { children?: React. const qualitySwitchRequestRef = useRef(0); const currentSongRef = useRef(null); const currentSourceRef = useRef(currentSource); + const lastMusicQueueSignatureRef = useRef(''); + + const isMusicRoomOwner = Boolean( + watchRoom?.isOwner && + watchRoom.currentRoom?.roomType === 'music' + ); + + const toMusicQueueItem = (song: Song): MusicQueueItem => ({ + id: song.id, + name: song.name, + artist: song.artist, + album: song.album, + pic: song.pic, + platform: song.platform || currentSourceRef.current, + songmid: song.songmid, + duration: song.duration, + durationText: song.durationText, + }); + + const buildMusicRoomState = ( + song: Song, + options: { + queue?: Song[]; + currentIndex?: number; + currentTime?: number; + isPlaying?: boolean; + } = {} + ): MusicState => { + 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); + + if (currentIndex < 0) { + queue = [...queue, { ...song, platform: songPlatform }]; + currentIndex = queue.length - 1; + } + + const currentQueueSong = { ...queue[currentIndex], platform: queue[currentIndex].platform || songPlatform }; + + return { + type: 'music', + queue: queue.map(toMusicQueueItem), + currentIndex, + song: toMusicQueueItem(currentQueueSong), + currentTime: options.currentTime ?? audioRef.current?.currentTime ?? currentTimeRef.current ?? 0, + isPlaying: options.isPlaying ?? isPlaying, + quality, + playMode, + updatedAt: Date.now(), + }; + }; + + 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 buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => { const params = new URLSearchParams({ @@ -630,6 +694,35 @@ export default function MusicClient({ children: _children }: { children?: React. currentSourceRef.current = currentSource; }, [currentSource]); + 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); + }, [isMusicRoomOwner, watchRoom, currentSong, playlist, playlistIndex, playMode, quality]); + + useEffect(() => { + if (!isMusicRoomOwner || !watchRoom || !currentSong || !isPlaying) return; + + const interval = window.setInterval(() => { + watchRoom.updateMusicState(buildMusicRoomState(currentSong, { + currentTime: audioRef.current?.currentTime || currentTimeRef.current || 0, + isPlaying: true, + })); + }, 5000); + + return () => window.clearInterval(interval); + }, [isMusicRoomOwner, watchRoom, currentSong, isPlaying, playlist, playlistIndex, playMode, quality]); + // 监听 playRecords 变化,更新 playlistIndex useEffect(() => { if (pendingSongToPlay) { @@ -725,6 +818,10 @@ export default function MusicClient({ children: _children }: { children?: React. const platform = song.platform || currentSource; 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(); @@ -775,6 +872,7 @@ export default function MusicClient({ children: _children }: { children?: React. }); saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0); + emitMusicChange(syncSong, syncQueue, syncIndex, true); if (proxyEnabled) { const streamUrl = buildStreamUrl(song, platform, quality); @@ -904,6 +1002,14 @@ export default function MusicClient({ children: _children }: { children?: React. if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); + if (isMusicRoomOwner) { + if (currentSong) { + watchRoom?.pauseMusic(buildMusicRoomState(currentSong, { + currentTime: audioRef.current.currentTime || currentTimeRef.current || 0, + isPlaying: false, + })); + } + } // 暂停时保存状态到 localStorage 和数据库 savePlayState(); @@ -926,6 +1032,14 @@ export default function MusicClient({ children: _children }: { children?: React. setIsBuffering(false); }); setIsPlaying(true); + if (isMusicRoomOwner) { + if (currentSong) { + watchRoom?.playMusic(buildMusicRoomState(currentSong, { + currentTime: audioRef.current.currentTime || currentTimeRef.current || 0, + isPlaying: true, + })); + } + } } } }; @@ -1337,6 +1451,15 @@ export default function MusicClient({ children: _children }: { children?: React. if (audioRef.current) { audioRef.current.currentTime = newTime; } + if (isMusicRoomOwner) { + const syncSong = currentSongRef.current || currentSong; + if (syncSong) { + watchRoom?.seekMusic(buildMusicRoomState(syncSong, { + currentTime: newTime, + isPlaying, + })); + } + } }; const seekToLyric = (line: LyricLine, index: number) => { @@ -1352,6 +1475,15 @@ export default function MusicClient({ children: _children }: { children?: React. audio.currentTime = nextTime; setCurrentTime(nextTime); setCurrentLyricIndex(index); + if (isMusicRoomOwner) { + const syncSong = currentSongRef.current || currentSong; + if (syncSong) { + watchRoom?.seekMusic(buildMusicRoomState(syncSong, { + currentTime: nextTime, + isPlaying, + })); + } + } }; // 音量调节 diff --git a/src/app/watch-room/music/page.tsx b/src/app/watch-room/music/page.tsx new file mode 100644 index 0000000..0556cc9 --- /dev/null +++ b/src/app/watch-room/music/page.tsx @@ -0,0 +1,646 @@ +'use client'; + +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'; + +interface LyricLine { + time: number; + text: string; + translation?: string; +} + +const SPECTRUM_BIN_COUNT = 72; +const SPECTRUM_EDGE_TRIM = 8; +const SPECTRUM_REFERENCE_VOLUME = 10; +const SPECTRUM_MIN_VOLUME = 5; +const SPECTRUM_MAX_REFERENCE_VOLUME = 15; +const SPECTRUM_IDLE_LEVEL = 0.04; + +function buildStreamUrl(song: MusicQueueItem, quality: string) { + const params = new URLSearchParams({ + songId: song.id, + source: song.platform, + quality, + songmid: song.songmid || song.id.split('_').slice(1).join('_'), + name: song.name, + artist: song.artist, + }); + + if (song.durationText) params.set('durationText', song.durationText); + return `/api/music/v2/stream?${params.toString()}`; +} + +function parseLyricText(text: string) { + const map = new Map(); + const timestampPattern = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g; + + text.split('\n').forEach((line) => { + const matches: Array = []; + timestampPattern.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = timestampPattern.exec(line)) !== null) { + matches.push(match); + } + + if (matches.length === 0) return; + const content = line.replace(/\[[^\]]+\]/g, '').trim(); + matches.forEach((current) => { + const min = Number(current[1] || 0); + const sec = Number(current[2] || 0); + const ms = Number((current[3] || '0').padEnd(3, '0')); + map.set(min * 60 + sec + ms / 1000, content); + }); + }); + return map; +} + +function parseLyric(lyricText = '', tlyricText = ''): LyricLine[] { + const main = parseLyricText(lyricText); + const trans = parseLyricText(tlyricText); + const times = Array.from(main.keys()); + trans.forEach((_value, key) => { + if (!times.includes(key)) times.push(key); + }); + times.sort((a, b) => a - b); + return times.map((time) => ({ + time, + text: main.get(time) || '', + translation: trans.get(time), + })).filter((line) => line.text || line.translation); +} + +function adjustedTime(state: Pick, playing: boolean) { + if (!playing) return state.currentTime; + return Math.max(0, state.currentTime + (Date.now() - state.updatedAt) / 1000); +} + +function formatTime(time: number) { + if (!Number.isFinite(time) || time < 0) return '--:--'; + const total = Math.floor(time); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function AudioSpectrumCanvas({ + bars, + compact = false, + volume = SPECTRUM_REFERENCE_VOLUME, +}: { + bars: number[]; + compact?: boolean; + volume?: number; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const draw = () => { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + const dpr = window.devicePixelRatio || 1; + const width = Math.round(rect.width * dpr); + const height = Math.round(rect.height * dpr); + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + + const targetPitch = compact ? 4.2 : 4.6; + const gap = Math.max(1, Math.round(dpr)); + const count = Math.max(1, Math.floor(rect.width / targetPitch)); + const barWidth = Math.max(2 * dpr, (width - gap * (count - 1)) / count); + const cubeHeight = compact ? Math.max(2, Math.round(2 * dpr)) : Math.max(2, Math.round(2.5 * dpr)); + const cubeGap = 1; + const scaleBase = compact ? height * 1.55 : height * 1.42; + + const sampleBar = (index: number) => { + const usableLength = Math.max(1, bars.length - SPECTRUM_EDGE_TRIM * 2); + const mappedStart = SPECTRUM_EDGE_TRIM + Math.floor((index / count) * usableLength); + const start = Math.min(bars.length - 1, mappedStart); + const mappedEnd = SPECTRUM_EDGE_TRIM + Math.max(mappedStart + 1, Math.floor(((index + 1) / count) * usableLength)); + const end = Math.min(bars.length, Math.max(start + 1, mappedEnd)); + let total = 0; + for (let i = start; i < end; i++) total += bars[i] ?? 0; + return total / Math.max(1, end - start); + }; + + ctx.fillStyle = '#10b981'; + ctx.strokeStyle = '#10b981'; + + const visualVolume = Math.max(SPECTRUM_MIN_VOLUME, volume || SPECTRUM_REFERENCE_VOLUME); + const visualVolumeScale = + visualVolume > SPECTRUM_MAX_REFERENCE_VOLUME + ? Math.sqrt(SPECTRUM_MAX_REFERENCE_VOLUME / visualVolume) + : SPECTRUM_REFERENCE_VOLUME / visualVolume; + + for (let i = 0; i < count; i++) { + const q = Math.max(SPECTRUM_IDLE_LEVEL, sampleBar(i)) * scaleBase * visualVolumeScale; + const cubeCount = Math.max(1, Math.ceil(q / Math.max(1, barWidth * 0.9))); + const x = i === count - 1 ? width - barWidth : i * (barWidth + gap); + + for (let segment = 0; segment < cubeCount; segment++) { + const y = height - segment * (cubeHeight + cubeGap); + ctx.beginPath(); + ctx.roundRect(x, y - cubeHeight, barWidth, cubeHeight, Math.min(2 * dpr, cubeHeight / 2)); + ctx.fill(); + } + } + }; + + draw(); + const observer = new ResizeObserver(draw); + observer.observe(canvas); + return () => observer.disconnect(); + }, [bars, compact, volume]); + + return ( + + ); +} + +const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,')`; + +function VinylTurntable({ song, isPlaying }: { song: MusicQueueItem; isPlaying: boolean }) { + return ( +
+
+
+
+
+
+ {song.pic ? ( + {song.name} + ) : ( +
+ )} +
+
+
+
+ ); +} + +export default function WatchRoomMusicPage() { + const router = useRouter(); + const watchRoom = useWatchRoomContext(); + const { currentRoom, isOwner, socket } = watchRoom; + const audioRef = useRef(null); + const analyserRef = useRef(null); + const mediaSourceRef = useRef(null); + const audioContextRef = useRef(null); + const frameRef = useRef(null); + const lastSongKeyRef = useRef(''); + const volumeRef = useRef(100); + const playbackRequestIdRef = useRef(0); + const lyricRequestIdRef = useRef(0); + const lyricsContainerRef = useRef(null); + + const [state, setState] = useState(() => ( + currentRoom?.currentState?.type === 'music' ? currentRoom.currentState : null + )); + const [lyrics, setLyrics] = useState([]); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [needsActivation, setNeedsActivation] = useState(true); + const [volume, setVolume] = useState(100); + const [isDark, setIsDark] = useState(true); + const [showVolumeSlider, setShowVolumeSlider] = useState(false); + const [mobilePanel, setMobilePanel] = useState<'cover' | 'lyrics'>('cover'); + const [bars, setBars] = useState(() => Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL)); + + useEffect(() => { + const nextState = + currentRoom?.roomType === 'music' && currentRoom.currentState?.type === 'music' + ? currentRoom.currentState + : null; + + setState((prev) => { + if (prev === nextState) return prev; + return nextState; + }); + + if (!nextState) { + playbackRequestIdRef.current += 1; + audioRef.current?.pause(); + setCurrentTime(0); + setDuration(0); + return; + } + + setCurrentTime(adjustedTime(nextState, nextState.isPlaying)); + if (Number.isFinite(nextState.song.duration) && nextState.song.duration) { + setDuration(nextState.song.duration); + } + }, [currentRoom?.currentState, currentRoom?.id, currentRoom?.roomType]); + + const currentLyricIndex = useMemo(() => { + let index = -1; + for (let i = 0; i < lyrics.length; i++) { + if (lyrics[i].time <= currentTime) index = i; + else break; + } + return index; + }, [lyrics, currentTime]); + + useEffect(() => { + if (currentLyricIndex < 0) return; + const container = lyricsContainerRef.current; + if (!container) return; + const active = container.querySelector(`[data-lyric-index="${currentLyricIndex}"]`); + if (!active) return; + active.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }, [currentLyricIndex]); + + useEffect(() => { + if (!currentRoom) { + router.replace('/watch-room'); + return; + } + if (currentRoom.roomType !== 'music' || isOwner) { + router.replace('/watch-room'); + } + }, [currentRoom, isOwner, router]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const syncTheme = () => setIsDark(document.documentElement.classList.contains('dark')); + syncTheme(); + + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + volumeRef.current = volume; + if (audioRef.current) { + audioRef.current.volume = volume / 100; + } + }, [volume]); + + const ensureAnalyser = async () => { + const audio = audioRef.current; + if (!audio || typeof window === 'undefined') return; + const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextClass) return; + + if (!audioContextRef.current) audioContextRef.current = new AudioContextClass(); + if (!mediaSourceRef.current) mediaSourceRef.current = audioContextRef.current.createMediaElementSource(audio); + if (!analyserRef.current) { + const analyser = audioContextRef.current.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.82; + mediaSourceRef.current.connect(analyser); + analyser.connect(audioContextRef.current.destination); + analyserRef.current = analyser; + } + if (audioContextRef.current.state === 'suspended') await audioContextRef.current.resume(); + }; + + const applyPlaybackState = async (nextState: MusicState) => { + const audio = audioRef.current; + if (!audio) return; + const requestId = ++playbackRequestIdRef.current; + + const key = `${nextState.song.platform}:${nextState.song.id}:${nextState.quality}`; + if (key !== lastSongKeyRef.current) { + lastSongKeyRef.current = key; + const lyricRequestId = ++lyricRequestIdRef.current; + audio.src = buildStreamUrl(nextState.song, nextState.quality); + audio.load(); + setLyrics([]); + + fetch('/api/music/v2/lyric', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + song: { + songId: nextState.song.id, + source: nextState.song.platform, + name: nextState.song.name, + singer: nextState.song.artist, + songmid: nextState.song.songmid, + }, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (lyricRequestId !== lyricRequestIdRef.current) return; + if (!data.success) return; + const lyricText = typeof data.data?.lyric === 'string' ? data.data.lyric : data.data?.lyric?.lyric ?? ''; + const tlyricText = typeof data.data?.tlyric === 'string' ? data.data.tlyric : data.data?.lyric?.tlyric ?? ''; + setLyrics(parseLyric(lyricText, tlyricText)); + }) + .catch(() => undefined); + } + + if (requestId !== playbackRequestIdRef.current) return; + + const targetTime = adjustedTime(nextState, nextState.isPlaying); + const seek = () => { + if (requestId !== playbackRequestIdRef.current) return; + if (Number.isFinite(targetTime) && Math.abs(audio.currentTime - targetTime) > 0.8) { + audio.currentTime = Math.min(targetTime, Number.isFinite(audio.duration) ? Math.max(0, audio.duration - 0.25) : targetTime); + } + }; + + if (audio.readyState >= 1) seek(); + else audio.addEventListener('loadedmetadata', seek, { once: true }); + + if (requestId !== playbackRequestIdRef.current) return; + + if (nextState.isPlaying && !needsActivation) { + await ensureAnalyser(); + if (requestId !== playbackRequestIdRef.current) return; + try { + await audio.play(); + } catch { + if (requestId === playbackRequestIdRef.current) { + setNeedsActivation(true); + } + } + if (requestId !== playbackRequestIdRef.current || !nextState.isPlaying) { + audio.pause(); + } + } else { + audio.pause(); + } + }; + + useEffect(() => { + if (!state) return; + void applyPlaybackState(state); + }, [state, needsActivation]); + + useEffect(() => { + if (!socket) return; + + const handleState = (nextState: MusicState) => { + setState(nextState); + setCurrentTime(adjustedTime(nextState, nextState.isPlaying)); + if (Number.isFinite(nextState.song.duration) && nextState.song.duration) { + setDuration(nextState.song.duration); + } + }; + socket.on('music:change', handleState); + socket.on('music:update', handleState); + socket.on('music:queue', handleState); + socket.on('music:play', handleState); + socket.on('music:pause', handleState); + socket.on('music:seek', handleState); + + return () => { + socket.off('music:change', handleState); + socket.off('music:update', handleState); + socket.off('music:queue', handleState); + socket.off('music:play', handleState); + socket.off('music:pause', handleState); + socket.off('music:seek', handleState); + }; + }, [socket]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const onTimeUpdate = () => setCurrentTime(audio.currentTime || 0); + const onDuration = () => setDuration(Number.isFinite(audio.duration) ? audio.duration : 0); + audio.addEventListener('timeupdate', onTimeUpdate); + audio.addEventListener('durationchange', onDuration); + audio.addEventListener('loadedmetadata', onDuration); + audio.addEventListener('ended', () => audio.pause()); + + return () => { + audio.removeEventListener('timeupdate', onTimeUpdate); + audio.removeEventListener('durationchange', onDuration); + audio.removeEventListener('loadedmetadata', onDuration); + }; + }, []); + + useEffect(() => { + const tick = () => { + const analyser = analyserRef.current; + if (analyser) { + const data = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(data); + setBars(Array.from({ length: SPECTRUM_BIN_COUNT }, (_, index) => { + const start = Math.floor((index / SPECTRUM_BIN_COUNT) * data.length); + const end = Math.max(start + 1, Math.floor(((index + 1) / SPECTRUM_BIN_COUNT) * data.length)); + let total = 0; + for (let i = start; i < end; i++) total += data[i] || 0; + return Math.max(SPECTRUM_IDLE_LEVEL, Math.min(1, total / Math.max(1, end - start) / 255)); + })); + } + frameRef.current = window.requestAnimationFrame(tick); + }; + + frameRef.current = window.requestAnimationFrame(tick); + return () => { + if (frameRef.current) window.cancelAnimationFrame(frameRef.current); + audioContextRef.current?.close().catch(() => undefined); + }; + }, []); + + const activate = async () => { + setNeedsActivation(false); + await ensureAnalyser(); + if (state?.isPlaying) { + audioRef.current?.play().catch(() => setNeedsActivation(true)); + } + }; + + 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 themeRootClass = isDark ? 'bg-zinc-950 text-white' : 'bg-white text-zinc-900'; + const showCoverPanel = mobilePanel === 'cover'; + const showLyricsPanel = mobilePanel === 'lyrics'; + const isPlaying = Boolean(state?.isPlaying); + const lyricActiveClass = isDark + ? 'scale-105 text-lg font-bold text-emerald-300 md:text-2xl' + : 'scale-105 text-lg font-bold text-emerald-600 md:text-2xl'; + const lyricNearbyClass = isDark ? 'text-base text-zinc-400' : 'text-base text-zinc-500'; + const lyricIdleClass = isDark ? 'text-sm text-zinc-600' : 'text-sm text-zinc-500'; + + return ( +
+ +
+ ); +} diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx index 6b54ba2..d6abce0 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -48,6 +48,7 @@ export default function WatchRoomPage() { const watchRoom = useWatchRoomContext(); const { getRoomList, isConnected, createRoom, joinRoom, currentRoom, isOwner, members, socket } = watchRoom; const [activeTab, setActiveTab] = useState('create'); + const [musicEnabled, setMusicEnabled] = useState(false); // 获取当前登录用户(在客户端挂载后读取,避免 hydration 错误) const [currentUsername, setCurrentUsername] = useState('游客'); @@ -57,6 +58,10 @@ export default function WatchRoomPage() { setCurrentUsername(authInfo?.username || '游客'); }, []); + useEffect(() => { + setMusicEnabled(Boolean((window as any).RUNTIME_CONFIG?.MUSIC_ENABLED)); + }, []); + // 创建房间表单 const [createForm, setCreateForm] = useState({ roomName: '', @@ -211,6 +216,11 @@ export default function WatchRoomPage() { return; } + if (currentRoom.roomType === 'music') { + router.push('/watch-room/music'); + return; + } + // 房员加入房间后,不立即跳转 // 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用) // 这样可以避免房主已经离开play页面但状态未清除的情况 @@ -223,7 +233,7 @@ export default function WatchRoomPage() { useEffect(() => { if (!currentRoom || isOwner) return; - if (currentRoom.roomType === 'screen') return; + if (currentRoom.roomType === 'screen' || currentRoom.roomType === 'music') return; const handlePlayChange = (state: any) => { if (state.type === 'play') { @@ -272,8 +282,10 @@ export default function WatchRoomPage() { useEffect(() => { if (currentRoom?.roomType === 'screen') { router.push('/watch-room/screen'); + } else if (currentRoom?.roomType === 'music' && !isOwner) { + router.push('/watch-room/music'); } - }, [currentRoom?.id, currentRoom?.roomType, router]); + }, [currentRoom?.id, currentRoom?.roomType, isOwner, router]); // 从房间列表加入房间 const handleJoinFromList = (room: Room) => { @@ -464,7 +476,7 @@ export default function WatchRoomPage() {

房间类型

-

{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}

+

{currentRoom.roomType === 'screen' ? '屏幕共享' : currentRoom.roomType === 'music' ? '一起听' : '进度同步'}

@@ -501,9 +513,20 @@ export default function WatchRoomPage() {

💡 {currentRoom.roomType === 'screen' ? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享' - : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'} + : currentRoom.roomType === 'music' + ? '进入音乐页面后,房间成员将同步收听您的播放列表' + : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}

+ {currentRoom.roomType === 'music' && isOwner && ( + + )} ) : (
@@ -574,7 +597,7 @@ export default function WatchRoomPage() { -
+
+ {musicEnabled && ( + + )}
@@ -843,7 +880,7 @@ export default function WatchRoomPage() {
类型 - {room.roomType === 'screen' ? '屏幕共享' : '进度同步'} + {room.roomType === 'screen' ? '屏幕共享' : room.roomType === 'music' ? '一起听' : '进度同步'}
创建时间 @@ -856,7 +893,9 @@ export default function WatchRoomPage() { ? `正在播放: ${room.currentState.videoName}` : room.currentState.type === 'live' ? `正在观看: ${room.currentState.channelName}` - : '正在共享屏幕'} + : room.currentState.type === 'music' + ? `正在听: ${room.currentState.song.name} - ${room.currentState.song.artist}` + : '正在共享屏幕'}

)} diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx index 0bd73c1..e7bd2e3 100644 --- a/src/app/watch-room/screen/page.tsx +++ b/src/app/watch-room/screen/page.tsx @@ -11,6 +11,8 @@ import { screenShareQualityOptions, type ScreenShareQualityPreset, useScreenShar const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_'; const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect'; +const WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY = 'watch_room_no_connect_timestamp'; +const WATCH_ROOM_NO_CONNECT_TTL_MS = 10 * 60 * 1000; const SCREEN_SHARE_QUALITY_KEY = 'watch_room_screen_quality'; function getScreenShareHostSupportError() { @@ -115,6 +117,10 @@ export default function WatchRoomScreenPage() { if (!screenRoom || !isOwner) return; localStorage.setItem(WATCH_ROOM_NO_CONNECT_KEY, '1'); + localStorage.setItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY, String(Date.now())); + const heartbeat = window.setInterval(() => { + localStorage.setItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY, String(Date.now())); + }, 30_000); const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`; if (!sessionStorage.getItem(key)) { sessionStorage.setItem(key, '1'); @@ -123,6 +129,8 @@ export default function WatchRoomScreenPage() { return () => { localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY); + localStorage.removeItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY); + window.clearInterval(heartbeat); }; }, [isOwner, openDetachedPage, screenRoom?.id]); diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index cb464a6..8a99fc0 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -2,6 +2,7 @@ 'use client'; import React, { createContext, useCallback,useContext, useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; import { useWatchRoom } from '@/hooks/useWatchRoom'; @@ -9,12 +10,14 @@ import Toast, { ToastProps } from '@/components/Toast'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; -import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room'; +import type { ChatMessage, Member, MusicState, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room'; // Import type from watch-room-socket type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket; const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect'; const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen'; +const WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY = 'watch_room_no_connect_timestamp'; +const WATCH_ROOM_NO_CONNECT_TTL_MS = 10 * 60 * 1000; interface WatchRoomContextType { socket: WatchRoomSocket | null; @@ -57,6 +60,12 @@ interface WatchRoomContextType { changeLiveChannel: (state: any) => void; startScreenShare: (state: ScreenState) => void; stopScreenShare: () => void; + changeMusic: (state: MusicState) => void; + updateMusicState: (state: MusicState) => void; + updateMusicQueue: (state: MusicState) => void; + playMusic: (state: MusicState) => void; + pauseMusic: (state: MusicState) => void; + seekMusic: (state: MusicState) => void; clearRoomState: () => void; // 重连 @@ -83,6 +92,7 @@ interface WatchRoomProviderProps { } export function WatchRoomProvider({ children }: WatchRoomProviderProps) { + const pathname = usePathname(); const [config, setConfig] = useState(null); const [isEnabled, setIsEnabled] = useState(false); const [toast, setToast] = useState(null); @@ -129,11 +139,25 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { useEffect(() => { if (typeof window === 'undefined') return; - setShouldDisableWatchRoomConnection( - window.location.pathname !== WATCH_ROOM_SCREEN_PATH - && window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1' - ); - }, []); + const refreshWatchRoomConnectionState = () => { + const noConnect = window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1'; + const lastActiveAt = Number(window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY) || 0); + const isScreenPage = pathname === WATCH_ROOM_SCREEN_PATH; + const isExpired = !lastActiveAt || Date.now() - lastActiveAt > WATCH_ROOM_NO_CONNECT_TTL_MS; + + if (noConnect && isExpired) { + window.localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY); + window.localStorage.removeItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY); + } + + setShouldDisableWatchRoomConnection(!isScreenPage && noConnect && !isExpired); + }; + + refreshWatchRoomConnectionState(); + const interval = window.setInterval(refreshWatchRoomConnectionState, 30_000); + + return () => window.clearInterval(interval); + }, [pathname]); // 检查登录状态 useEffect(() => { @@ -315,6 +339,12 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { changeLiveChannel: watchRoom.changeLiveChannel, startScreenShare: watchRoom.startScreenShare, stopScreenShare: watchRoom.stopScreenShare, + changeMusic: watchRoom.changeMusic, + updateMusicState: watchRoom.updateMusicState, + updateMusicQueue: watchRoom.updateMusicQueue, + playMusic: watchRoom.playMusic, + pauseMusic: watchRoom.pauseMusic, + seekMusic: watchRoom.seekMusic, clearRoomState: watchRoom.clearRoomState, manualReconnect, }; diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts index 6cf245f..ca5f852 100644 --- a/src/hooks/useWatchRoom.ts +++ b/src/hooks/useWatchRoom.ts @@ -9,6 +9,7 @@ import type { ChatMessage, LiveState, Member, + MusicState, PlayState, Room, RoomType, @@ -333,6 +334,66 @@ export function useWatchRoom( sock.emit('screen:stop'); }, [isOwner]); + const changeMusic = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:change', state); + }, + [isOwner] + ); + + const updateMusicState = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:update', state); + }, + [isOwner] + ); + + const updateMusicQueue = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:queue', state); + }, + [isOwner] + ); + + const playMusic = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:play', state); + }, + [isOwner] + ); + + const pauseMusic = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:pause', state); + }, + [isOwner] + ); + + const seekMusic = useCallback( + (state: MusicState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('music:seek', state); + }, + [isOwner] + ); + // 清除房间播放状态(房主离开播放/直播页面时调用) const clearRoomState = useCallback(() => { const sock = watchRoomSocketManager.getSocket(); @@ -417,6 +478,43 @@ export function useWatchRoom( } }); + const handleMusicState = (state: MusicState) => { + if (currentRoom) { + setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null)); + } + }; + + socket.on('music:change', handleMusicState); + socket.on('music:update', handleMusicState); + socket.on('music:queue', handleMusicState); + socket.on('music:play', (state) => { + setCurrentRoom((prev) => { + if (!prev || prev.currentState?.type !== 'music') return prev; + return { + ...prev, + currentState: { ...prev.currentState, ...state, isPlaying: true }, + }; + }); + }); + socket.on('music:pause', (state) => { + setCurrentRoom((prev) => { + if (!prev || prev.currentState?.type !== 'music') return prev; + return { + ...prev, + currentState: { ...prev.currentState, ...state, isPlaying: false }, + }; + }); + }); + socket.on('music:seek', (state) => { + setCurrentRoom((prev) => { + if (!prev || prev.currentState?.type !== 'music') return prev; + return { + ...prev, + currentState: { ...prev.currentState, ...state }, + }; + }); + }); + // 聊天事件 socket.on('chat:message', (message) => { setChatMessages((prev) => [...prev, message]); @@ -456,6 +554,12 @@ export function useWatchRoom( socket.off('live:change'); socket.off('screen:start'); socket.off('screen:stop'); + socket.off('music:change'); + socket.off('music:update'); + socket.off('music:queue'); + socket.off('music:play'); + socket.off('music:pause'); + socket.off('music:seek'); socket.off('chat:message'); socket.off('state:cleared'); socket.off('connect'); @@ -494,6 +598,12 @@ export function useWatchRoom( changeLiveChannel, startScreenShare, stopScreenShare, + changeMusic, + updateMusicState, + updateMusicQueue, + playMusic, + pauseMusic, + seekMusic, clearRoomState, }; } diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts index a4bcd30..6c654f2 100644 --- a/src/lib/watch-room-server.ts +++ b/src/lib/watch-room-server.ts @@ -173,7 +173,7 @@ export class WatchRoomServer { // 播放进度跳转 socket.on('play:seek', (currentTime) => { const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo || !roomInfo.isOwner) return; socket.to(roomInfo.roomId).emit('play:seek', currentTime); }); @@ -181,7 +181,7 @@ export class WatchRoomServer { // 播放 socket.on('play:play', () => { const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo || !roomInfo.isOwner) return; socket.to(roomInfo.roomId).emit('play:play'); }); @@ -189,7 +189,7 @@ export class WatchRoomServer { // 暂停 socket.on('play:pause', () => { const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo || !roomInfo.isOwner) return; socket.to(roomInfo.roomId).emit('play:pause'); }); @@ -220,6 +220,78 @@ export class WatchRoomServer { } }); + socket.on('music:change', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:change', state); + } + }); + + socket.on('music:update', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:update', state); + } + }); + + socket.on('music:queue', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = state; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:queue', state); + } + }); + + socket.on('music:play', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state, isPlaying: true }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:play', state); + } + }); + + socket.on('music:pause', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state, isPlaying: false }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:pause', state); + } + }); + + socket.on('music:seek', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo || !roomInfo.isOwner) return; + + const room = this.rooms.get(roomInfo.roomId); + if (room?.roomType === 'music') { + room.currentState = { ...state }; + this.rooms.set(roomInfo.roomId, room); + socket.to(roomInfo.roomId).emit('music:seek', state); + } + }); + socket.on('screen:helper-register', (data, callback) => { try { const room = this.rooms.get(data.roomId); diff --git a/src/types/watch-room.ts b/src/types/watch-room.ts index a77b914..77ffe29 100644 --- a/src/types/watch-room.ts +++ b/src/types/watch-room.ts @@ -11,12 +11,12 @@ export interface Room { ownerName: string; ownerToken: string; // 房主令牌,用于重连时验证身份 memberCount: number; - currentState: PlayState | LiveState | ScreenState | null; + currentState: PlayState | LiveState | ScreenState | MusicState | null; createdAt: number; lastOwnerHeartbeat: number; } -export type RoomType = 'sync' | 'screen'; +export type RoomType = 'sync' | 'screen' | 'music'; export interface Member { id: string; @@ -53,6 +53,30 @@ export interface ScreenState { startedAt?: number; } +export interface MusicQueueItem { + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + platform: string; + songmid?: string; + duration?: number; + durationText?: string; +} + +export interface MusicState { + type: 'music'; + queue: MusicQueueItem[]; + currentIndex: number; + song: MusicQueueItem; + currentTime: number; + isPlaying: boolean; + quality: string; + playMode: 'loop' | 'single' | 'random'; + updatedAt: number; +} + export interface ChatMessage { id: string; userId: string; @@ -90,6 +114,12 @@ export interface ServerToClientEvents { 'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void; 'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void; 'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void; + 'music:change': (state: MusicState) => void; + 'music:update': (state: MusicState) => void; + 'music:play': (state: MusicState) => void; + 'music:pause': (state: MusicState) => void; + 'music:seek': (state: MusicState) => void; + 'music:queue': (state: MusicState) => void; 'chat:message': (message: ChatMessage) => void; 'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void; 'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void; @@ -139,6 +169,12 @@ export interface ClientToServerEvents { 'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void; 'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void; 'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void; + 'music:change': (state: MusicState) => void; + 'music:update': (state: MusicState) => void; + 'music:play': (state: MusicState) => void; + 'music:pause': (state: MusicState) => void; + 'music:seek': (state: MusicState) => void; + 'music:queue': (state: MusicState) => void; 'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;