增加一起听功能

This commit is contained in:
mtvpls
2026-05-27 22:57:59 +08:00
parent 540614cef9
commit 23290f027b
9 changed files with 1167 additions and 22 deletions
+132
View File
@@ -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<MusicSource>('wy');
const [currentSong, setCurrentSong] = useState<Song | null>(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<Song | null>(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,
}));
}
}
};
// 音量调节
+646
View File
@@ -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<number, string>();
const timestampPattern = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g;
text.split('\n').forEach((line) => {
const matches: Array<RegExpExecArray> = [];
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<MusicState, 'currentTime' | 'updatedAt'>, 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<HTMLCanvasElement>(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 (
<div className={`relative w-full overflow-hidden ${compact ? 'h-6' : 'h-8'}`} aria-hidden="true">
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full opacity-50" />
</div>
);
}
const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>')`;
function VinylTurntable({ song, isPlaying }: { song: MusicQueueItem; isPlaying: boolean }) {
return (
<div className="relative mx-auto mt-12 mb-5 flex h-[280px] w-[280px] items-center justify-center md:mt-16 md:mb-8 md:h-[340px] md:w-[340px]">
<div
className="pointer-events-none absolute left-1/2 top-[-54px] z-20 h-[140px] w-[108px] drop-shadow-xl transition-transform duration-500"
style={{
marginLeft: '-20px',
backgroundImage: VINYL_NEEDLE_SVG,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
transformOrigin: '20px 20px',
transform: isPlaying ? 'rotate(0deg)' : 'rotate(-30deg)',
}}
/>
<div
className="relative flex h-[246px] w-[246px] items-center justify-center overflow-hidden rounded-full md:h-[300px] md:w-[300px]"
style={{
background: 'conic-gradient(from 45deg, #070707 0%, #2b2b2b 10%, #101010 20%, #080808 32%, #242424 42%, #0b0b0b 55%, #1e1e1e 68%, #050505 80%, #2c2c2c 90%, #070707 100%)',
boxShadow: '0 0 0 8px rgba(255,255,255,0.055), 0 20px 42px rgba(0,0,0,0.65), inset 0 0 28px rgba(255,255,255,0.045)',
animation: 'music-room-vinyl-spin 20s linear infinite',
animationPlayState: isPlaying ? 'running' : 'paused',
}}
>
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: 'repeating-radial-gradient(circle, transparent 0, transparent 3px, rgba(255,255,255,0.055) 3px, rgba(255,255,255,0.055) 4px)',
}}
/>
<div className="pointer-events-none absolute left-[18%] top-[10%] h-[42%] w-[22%] rotate-[-28deg] rounded-full bg-white/10 blur-md" />
<div className="relative z-10 flex h-[158px] w-[158px] items-center justify-center overflow-hidden rounded-full border-[5px] border-black bg-zinc-800 md:h-[192px] md:w-[192px]">
{song.pic ? (
<img src={song.pic} alt={song.name} className="h-full w-full rounded-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-4xl text-zinc-500"></div>
)}
</div>
<div className="absolute z-20 h-3 w-3 rounded-full bg-zinc-950 ring-1 ring-white/30" />
</div>
</div>
);
}
export default function WatchRoomMusicPage() {
const router = useRouter();
const watchRoom = useWatchRoomContext();
const { currentRoom, isOwner, socket } = watchRoom;
const audioRef = useRef<HTMLAudioElement>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const frameRef = useRef<number | null>(null);
const lastSongKeyRef = useRef('');
const volumeRef = useRef(100);
const playbackRequestIdRef = useRef(0);
const lyricRequestIdRef = useRef(0);
const lyricsContainerRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<MusicState | null>(() => (
currentRoom?.currentState?.type === 'music' ? currentRoom.currentState : null
));
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
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<number[]>(() => 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<HTMLElement>(`[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 (
<main className={`relative min-h-screen overflow-hidden transition-colors ${themeRootClass}`}>
<style>{`
@keyframes music-room-vinyl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
<audio ref={audioRef} className="hidden" />
{song?.pic && (
<img src={song.pic} alt="" className={`absolute inset-0 h-full w-full object-cover blur-3xl ${isDark ? 'opacity-20' : 'opacity-12'}`} />
)}
<div className={`absolute inset-0 ${isDark ? 'bg-zinc-950/80' : 'bg-white/75'}`} />
<section className="relative z-10 mx-auto flex min-h-screen max-w-6xl flex-col px-5 py-6">
<div className={`mb-4 flex items-center justify-between gap-4 text-sm ${isDark ? 'text-zinc-400' : 'text-zinc-600'}`}>
<button
onClick={() => router.push('/watch-room')}
className={`rounded-md border px-3 py-2 transition-colors ${isDark ? 'border-white/10 text-zinc-300 hover:bg-white/10' : 'border-zinc-200 text-zinc-700 hover:bg-zinc-100'}`}
>
</button>
<span className="truncate">{currentRoom?.name || '-'}</span>
</div>
{song ? (
<>
<div className="md:hidden flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setMobilePanel('cover')}
className={`rounded-full px-4 py-2 text-sm transition-colors ${showCoverPanel ? 'bg-emerald-500 text-white' : isDark ? 'bg-white/10 text-zinc-300' : 'bg-zinc-100 text-zinc-700'}`}
>
</button>
<button
type="button"
onClick={() => setMobilePanel('lyrics')}
className={`rounded-full px-4 py-2 text-sm transition-colors ${showLyricsPanel ? 'bg-emerald-500 text-white' : isDark ? 'bg-white/10 text-zinc-300' : 'bg-zinc-100 text-zinc-700'}`}
>
</button>
</div>
<div className="grid flex-1 gap-4 md:grid-cols-[420px_minmax(0,1fr)]">
<div className={`${showCoverPanel ? 'block' : 'hidden'} min-w-0 md:block`}>
<div className={`rounded-lg border p-4 md:p-6 ${isDark ? 'border-white/10 bg-black/20' : 'border-zinc-200 bg-white/80 shadow-sm'}`}>
<div className="relative">
<button
type="button"
onClick={() => setShowVolumeSlider((prev) => !prev)}
className={`absolute right-0 top-0 z-10 shrink-0 transition-colors ${isDark ? 'text-zinc-400 hover:text-white' : 'text-zinc-600 hover:text-zinc-900'}`}
title="音量"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
</svg>
</button>
<div className={`absolute right-0 top-6 z-20 transition-opacity ${showVolumeSlider ? 'opacity-100' : 'pointer-events-none opacity-0'}`}>
<div className={`rounded-lg border p-3 shadow-xl ${isDark ? 'border-white/10 bg-zinc-900/95' : 'border-zinc-200 bg-white'}`}>
<div className="flex flex-col items-center gap-2">
<span className={`text-xs font-mono ${isDark ? 'text-zinc-400' : 'text-zinc-600'}`}>{volume}</span>
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="h-24 w-2 cursor-pointer appearance-none rounded-full accent-emerald-400"
style={{ writingMode: 'vertical-lr', WebkitAppearance: 'slider-vertical' }}
/>
</div>
</div>
</div>
</div>
<VinylTurntable song={song} isPlaying={Boolean(state?.isPlaying)} />
<AudioSpectrumCanvas bars={bars} compact volume={volume} />
<div className="mt-4 text-center">
<h1 className="truncate text-xl font-bold md:text-3xl">{song.name}</h1>
<p className={`mt-2 truncate text-sm md:text-base ${isDark ? 'text-zinc-400' : 'text-zinc-600'}`}>{song.artist}</p>
{nextSong && <p className={`mt-2 truncate text-xs md:text-sm ${isDark ? 'text-zinc-500' : 'text-zinc-600'}`}>{nextSong.name} - {nextSong.artist}</p>}
</div>
<div className="mt-5 flex items-center gap-2 text-xs tabular-nums">
<span className={`w-10 ${isDark ? 'text-zinc-500' : 'text-zinc-600'}`}>{formatTime(currentTime)}</span>
<div className={`relative h-1.5 flex-1 overflow-hidden rounded-full ${isDark ? 'bg-white/10' : 'bg-zinc-200'}`}>
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${progress}%` }} />
</div>
<span className={`w-10 text-right ${isDark ? 'text-zinc-500' : 'text-zinc-600'}`}>{formatTime(duration)}</span>
</div>
</div>
</div>
<div
ref={lyricsContainerRef}
className={`${showLyricsPanel ? 'block' : 'hidden'} min-h-0 rounded-lg border p-4 md:block md:h-[70vh] md:overflow-y-auto md:p-6 ${isDark ? 'border-white/10 bg-black/20' : 'border-zinc-200 bg-white/80 shadow-sm'}`}
>
{lyrics.length > 0 ? (
<div className="space-y-4">
{lyrics.map((line, index) => (
<div
key={`${line.time}-${index}`}
data-lyric-index={index}
className={`text-center transition-all duration-300 ${
index === currentLyricIndex
? lyricActiveClass
: index === currentLyricIndex - 1 || index === currentLyricIndex + 1
? lyricNearbyClass
: lyricIdleClass
}`}
>
<div>{line.text || '♪'}</div>
{line.translation && <div className={`mt-1 text-sm font-normal ${isDark ? 'text-zinc-500' : 'text-zinc-400'}`}>{line.translation}</div>}
</div>
))}
</div>
) : (
<div className={`flex h-full min-h-[200px] items-center justify-center ${isDark ? 'text-zinc-500' : 'text-zinc-400'}`}></div>
)}
</div>
</div>
</>
) : (
<div className={`flex flex-1 items-center justify-center ${isDark ? 'text-zinc-400' : 'text-zinc-500'}`}></div>
)}
</section>
{needsActivation && song && (
<div className={`absolute inset-0 z-20 flex items-center justify-center backdrop-blur ${isDark ? 'bg-black/70' : 'bg-white/60'}`}>
<button
type="button"
onClick={activate}
className="rounded-full bg-emerald-500 px-8 py-4 text-base font-semibold text-white shadow-2xl hover:bg-emerald-600"
>
</button>
</div>
)}
</main>
);
}
+46 -7
View File
@@ -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<TabType>('create');
const [musicEnabled, setMusicEnabled] = useState(false);
// 获取当前登录用户(在客户端挂载后读取,避免 hydration 错误)
const [currentUsername, setCurrentUsername] = useState<string>('游客');
@@ -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() {
</div>
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
<p className="text-blue-100 text-xs mb-1"></p>
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : currentRoom.roomType === 'music' ? '一起听' : '进度同步'}</p>
</div>
</div>
</div>
@@ -501,9 +513,20 @@ export default function WatchRoomPage() {
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 {currentRoom.roomType === 'screen'
? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
: currentRoom.roomType === 'music'
? '进入音乐页面后,房间成员将同步收听您的播放列表'
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
</p>
</div>
{currentRoom.roomType === 'music' && isOwner && (
<button
type="button"
onClick={() => router.push('/music?watchRoom=music')}
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-3 rounded-lg transition-colors"
>
</button>
)}
</div>
) : (
<form onSubmit={handleCreateRoom} className="space-y-4">
@@ -574,7 +597,7 @@ export default function WatchRoomPage() {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className={`grid grid-cols-1 ${musicEnabled ? 'sm:grid-cols-3' : 'sm:grid-cols-2'} gap-3`}>
<button
type="button"
onClick={() => setCreateForm({ ...createForm, roomType: 'sync' })}
@@ -599,6 +622,20 @@ export default function WatchRoomPage() {
<div className="font-medium text-gray-900 dark:text-gray-100"></div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
{musicEnabled && (
<button
type="button"
onClick={() => setCreateForm({ ...createForm, roomType: 'music' })}
className={`rounded-lg border p-4 text-left transition-colors ${
createForm.roomType === 'music'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100"></div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
)}
</div>
</div>
@@ -843,7 +880,7 @@ export default function WatchRoomPage() {
</div>
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span></span>
<span>{room.roomType === 'screen' ? '屏幕共享' : '进度同步'}</span>
<span>{room.roomType === 'screen' ? '屏幕共享' : room.roomType === 'music' ? '一起听' : '进度同步'}</span>
</div>
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span></span>
@@ -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}`
: '正在共享屏幕'}
</p>
</div>
)}
+8
View File
@@ -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]);
+36 -6
View File
@@ -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<WatchRoomConfig | null>(null);
const [isEnabled, setIsEnabled] = useState(false);
const [toast, setToast] = useState<ToastProps | null>(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,
};
+110
View File
@@ -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,
};
}
+75 -3
View File
@@ -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);
+38 -2
View File
@@ -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;