优化一起听被控端
This commit is contained in:
@@ -15,7 +15,14 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const data = await fetchLxLyric(song);
|
const data = await fetchLxLyric(song);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: { lyric: data.lyric || '', tlyric: data.tlyric || '' } });
|
return NextResponse.json(
|
||||||
|
{ success: true, data: { lyric: data.lyric || '', tlyric: data.tlyric || '' } },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[music-v2] lyric route error:', error);
|
console.error('[music-v2] lyric route error:', error);
|
||||||
return internalError('获取歌词失败', (error as Error).message);
|
return internalError('获取歌词失败', (error as Error).message);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import MusicSidebarDrawer from '@/components/music/MusicSidebarDrawer';
|
|||||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||||
import { getSourceDisplayLabel, normalizeSource, SourcePill } from '@/lib/music/shared';
|
import { getSourceDisplayLabel, normalizeSource, SourcePill } from '@/lib/music/shared';
|
||||||
import type { MusicQuality, MusicSource, Song } from '@/lib/music/types';
|
import type { MusicQuality, MusicSource, Song } from '@/lib/music/types';
|
||||||
import type { MusicQueueItem, MusicState } from '@/types/watch-room';
|
import type { MusicQueueItem, MusicSyncState } from '@/types/watch-room';
|
||||||
|
|
||||||
const SPECTRUM_BIN_COUNT = 96;
|
const SPECTRUM_BIN_COUNT = 96;
|
||||||
const SPECTRUM_IDLE_LEVEL = 0.02;
|
const SPECTRUM_IDLE_LEVEL = 0.02;
|
||||||
@@ -50,6 +50,10 @@ interface DbRecord {
|
|||||||
songmid?: string;
|
songmid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMusicQueueItemKey(song: { id: string; platform?: string }, fallbackPlatform = '') {
|
||||||
|
return `${song.platform || fallbackPlatform}:${song.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
function AudioSpectrumCanvas({
|
function AudioSpectrumCanvas({
|
||||||
bars,
|
bars,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -305,7 +309,8 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
const qualitySwitchRequestRef = useRef(0);
|
const qualitySwitchRequestRef = useRef(0);
|
||||||
const currentSongRef = useRef<Song | null>(null);
|
const currentSongRef = useRef<Song | null>(null);
|
||||||
const currentSourceRef = useRef(currentSource);
|
const currentSourceRef = useRef(currentSource);
|
||||||
const lastMusicQueueSignatureRef = useRef('');
|
const plannedNextSongRef = useRef<Song | null>(null);
|
||||||
|
const plannedNextSongKeyRef = useRef('');
|
||||||
|
|
||||||
const isMusicRoomOwner = Boolean(
|
const isMusicRoomOwner = Boolean(
|
||||||
watchRoom?.isOwner &&
|
watchRoom?.isOwner &&
|
||||||
@@ -327,28 +332,57 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
const buildMusicRoomState = (
|
const buildMusicRoomState = (
|
||||||
song: Song,
|
song: Song,
|
||||||
options: {
|
options: {
|
||||||
queue?: Song[];
|
|
||||||
currentIndex?: number;
|
|
||||||
currentTime?: number;
|
currentTime?: number;
|
||||||
isPlaying?: boolean;
|
isPlaying?: boolean;
|
||||||
|
nextSong?: Song | null;
|
||||||
} = {}
|
} = {}
|
||||||
): MusicState => {
|
): MusicSyncState => {
|
||||||
const songPlatform = song.platform || currentSourceRef.current;
|
const songPlatform = song.platform || currentSourceRef.current;
|
||||||
let queue = options.queue && options.queue.length > 0 ? options.queue : playlist;
|
const currentSong = { ...song, platform: songPlatform };
|
||||||
let currentIndex = options.currentIndex ?? queue.findIndex((item) => item.id === song.id && (item.platform || currentSourceRef.current) === songPlatform);
|
const playlistSnapshot = playlist
|
||||||
|
.map((item) => ({ ...item, platform: item.platform || songPlatform }))
|
||||||
|
.filter((item) => item.id);
|
||||||
|
const currentKey = getMusicQueueItemKey(currentSong, songPlatform);
|
||||||
|
const playlistKey = playlistSnapshot.map((item) => getMusicQueueItemKey(item)).join('|');
|
||||||
|
const cacheKey = `${currentKey}|${playMode}|${playlistKey}`;
|
||||||
|
|
||||||
if (currentIndex < 0) {
|
const resolveNextSong = (): Song | null => {
|
||||||
queue = [...queue, { ...song, platform: songPlatform }];
|
if (typeof options.nextSong !== 'undefined') return options.nextSong || null;
|
||||||
currentIndex = queue.length - 1;
|
if (plannedNextSongKeyRef.current === cacheKey) {
|
||||||
}
|
return plannedNextSongRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
const currentQueueSong = { ...queue[currentIndex], platform: queue[currentIndex].platform || songPlatform };
|
let currentIndex = playlistSnapshot.findIndex((item) => getMusicQueueItemKey(item) === currentKey);
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
playlistSnapshot.push(currentSong);
|
||||||
|
currentIndex = playlistSnapshot.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSong: Song | null = null;
|
||||||
|
if (playMode === 'single') {
|
||||||
|
nextSong = playlistSnapshot[currentIndex] || null;
|
||||||
|
} else if (playMode === 'random') {
|
||||||
|
if (playlistSnapshot.length === 1) {
|
||||||
|
nextSong = playlistSnapshot[0] || null;
|
||||||
|
} else {
|
||||||
|
const candidates = playlistSnapshot.filter((_, index) => index !== currentIndex);
|
||||||
|
nextSong = candidates[Math.floor(Math.random() * candidates.length)] || null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nextIndex = currentIndex < playlistSnapshot.length - 1 ? currentIndex + 1 : 0;
|
||||||
|
nextSong = playlistSnapshot[nextIndex] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
plannedNextSongKeyRef.current = cacheKey;
|
||||||
|
plannedNextSongRef.current = nextSong;
|
||||||
|
return nextSong;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSong = resolveNextSong();
|
||||||
return {
|
return {
|
||||||
type: 'music',
|
type: 'music',
|
||||||
queue: queue.map(toMusicQueueItem),
|
song: toMusicQueueItem(currentSong),
|
||||||
currentIndex,
|
nextSong: nextSong ? toMusicQueueItem(nextSong) : null,
|
||||||
song: toMusicQueueItem(currentQueueSong),
|
|
||||||
currentTime: options.currentTime ?? audioRef.current?.currentTime ?? currentTimeRef.current ?? 0,
|
currentTime: options.currentTime ?? audioRef.current?.currentTime ?? currentTimeRef.current ?? 0,
|
||||||
isPlaying: options.isPlaying ?? isPlaying,
|
isPlaying: options.isPlaying ?? isPlaying,
|
||||||
quality,
|
quality,
|
||||||
@@ -357,14 +391,9 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitMusicChange = (song: Song | null, nextQueue?: Song[], nextIndex?: number, playing = true) => {
|
const emitMusicChange = (state: MusicSyncState | null) => {
|
||||||
if (!isMusicRoomOwner || !watchRoom || !song) return;
|
if (!isMusicRoomOwner || !watchRoom || !state) return;
|
||||||
watchRoom.changeMusic(buildMusicRoomState(song, {
|
watchRoom.changeMusic(state);
|
||||||
queue: nextQueue,
|
|
||||||
currentIndex: nextIndex,
|
|
||||||
currentTime: audioRef.current?.currentTime || 0,
|
|
||||||
isPlaying: playing,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => {
|
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => {
|
||||||
@@ -697,17 +726,7 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMusicRoomOwner || !watchRoom || !currentSong) return;
|
if (!isMusicRoomOwner || !watchRoom || !currentSong) return;
|
||||||
|
|
||||||
const state = buildMusicRoomState(currentSong);
|
watchRoom.updateMusicState(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]);
|
}, [isMusicRoomOwner, watchRoom, currentSong, playlist, playlistIndex, playMode, quality]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -819,9 +838,6 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
const proxyEnabled = getMusicProxyEnabled();
|
const proxyEnabled = getMusicProxyEnabled();
|
||||||
setMusicProxyEnabled(proxyEnabled);
|
setMusicProxyEnabled(proxyEnabled);
|
||||||
const syncSong = { ...song, platform };
|
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();
|
songStartTimeRef.current = Date.now();
|
||||||
@@ -872,7 +888,10 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
});
|
});
|
||||||
|
|
||||||
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0);
|
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0);
|
||||||
emitMusicChange(syncSong, syncQueue, syncIndex, true);
|
emitMusicChange(buildMusicRoomState(syncSong, {
|
||||||
|
currentTime: 0,
|
||||||
|
isPlaying: true,
|
||||||
|
}));
|
||||||
|
|
||||||
if (proxyEnabled) {
|
if (proxyEnabled) {
|
||||||
const streamUrl = buildStreamUrl(song, platform, quality);
|
const streamUrl = buildStreamUrl(song, platform, quality);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||||
import type { MusicQueueItem, MusicState } from '@/types/watch-room';
|
import type { MusicQueueItem, MusicSyncState } from '@/types/watch-room';
|
||||||
|
|
||||||
interface LyricLine {
|
interface LyricLine {
|
||||||
time: number;
|
time: number;
|
||||||
@@ -73,7 +73,7 @@ function parseLyric(lyricText = '', tlyricText = ''): LyricLine[] {
|
|||||||
})).filter((line) => line.text || line.translation);
|
})).filter((line) => line.text || line.translation);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustedTime(state: Pick<MusicState, 'currentTime' | 'updatedAt'>, playing: boolean) {
|
function adjustedTime(state: Pick<MusicSyncState, 'currentTime' | 'updatedAt'>, playing: boolean) {
|
||||||
if (!playing) return state.currentTime;
|
if (!playing) return state.currentTime;
|
||||||
return Math.max(0, state.currentTime + (Date.now() - state.updatedAt) / 1000);
|
return Math.max(0, state.currentTime + (Date.now() - state.updatedAt) / 1000);
|
||||||
}
|
}
|
||||||
@@ -232,9 +232,12 @@ export default function WatchRoomMusicPage() {
|
|||||||
const volumeRef = useRef(100);
|
const volumeRef = useRef(100);
|
||||||
const playbackRequestIdRef = useRef(0);
|
const playbackRequestIdRef = useRef(0);
|
||||||
const lyricRequestIdRef = useRef(0);
|
const lyricRequestIdRef = useRef(0);
|
||||||
const lyricsContainerRef = useRef<HTMLDivElement>(null);
|
const mobileLyricsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const desktopLyricsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileVolumeControlRef = useRef<HTMLDivElement>(null);
|
||||||
|
const desktopVolumeControlRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [state, setState] = useState<MusicState | null>(() => (
|
const [state, setState] = useState<MusicSyncState | null>(() => (
|
||||||
currentRoom?.currentState?.type === 'music' ? currentRoom.currentState : null
|
currentRoom?.currentState?.type === 'music' ? currentRoom.currentState : null
|
||||||
));
|
));
|
||||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||||
@@ -283,7 +286,7 @@ export default function WatchRoomMusicPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentLyricIndex < 0) return;
|
if (currentLyricIndex < 0) return;
|
||||||
const container = lyricsContainerRef.current;
|
const container = window.innerWidth < 768 ? mobileLyricsContainerRef.current : desktopLyricsContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const active = container.querySelector<HTMLElement>(`[data-lyric-index="${currentLyricIndex}"]`);
|
const active = container.querySelector<HTMLElement>(`[data-lyric-index="${currentLyricIndex}"]`);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -311,6 +314,22 @@ export default function WatchRoomMusicPage() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!showVolumeSlider) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
const insideMobile = mobileVolumeControlRef.current?.contains(target) ?? false;
|
||||||
|
const insideDesktop = desktopVolumeControlRef.current?.contains(target) ?? false;
|
||||||
|
if (!insideMobile && !insideDesktop) {
|
||||||
|
setShowVolumeSlider(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
}, [showVolumeSlider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
volumeRef.current = volume;
|
volumeRef.current = volume;
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
@@ -337,7 +356,7 @@ export default function WatchRoomMusicPage() {
|
|||||||
if (audioContextRef.current.state === 'suspended') await audioContextRef.current.resume();
|
if (audioContextRef.current.state === 'suspended') await audioContextRef.current.resume();
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPlaybackState = async (nextState: MusicState) => {
|
const applyPlaybackState = async (nextState: MusicSyncState) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
const requestId = ++playbackRequestIdRef.current;
|
const requestId = ++playbackRequestIdRef.current;
|
||||||
@@ -415,7 +434,7 @@ export default function WatchRoomMusicPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
|
||||||
const handleState = (nextState: MusicState) => {
|
const handleState = (nextState: MusicSyncState) => {
|
||||||
setState(nextState);
|
setState(nextState);
|
||||||
setCurrentTime(adjustedTime(nextState, nextState.isPlaying));
|
setCurrentTime(adjustedTime(nextState, nextState.isPlaying));
|
||||||
if (Number.isFinite(nextState.song.duration) && nextState.song.duration) {
|
if (Number.isFinite(nextState.song.duration) && nextState.song.duration) {
|
||||||
@@ -491,7 +510,7 @@ export default function WatchRoomMusicPage() {
|
|||||||
|
|
||||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
|
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
|
||||||
const song = state?.song;
|
const song = state?.song;
|
||||||
const nextSong = state && state.queue.length > 1 ? state.queue[(state.currentIndex + 1) % state.queue.length] : null;
|
const nextSong = state?.nextSong || null;
|
||||||
const themeRootClass = isDark ? 'bg-zinc-950 text-white' : 'bg-white text-zinc-900';
|
const themeRootClass = isDark ? 'bg-zinc-950 text-white' : 'bg-white text-zinc-900';
|
||||||
const showCoverPanel = mobilePanel === 'cover';
|
const showCoverPanel = mobilePanel === 'cover';
|
||||||
const showLyricsPanel = mobilePanel === 'lyrics';
|
const showLyricsPanel = mobilePanel === 'lyrics';
|
||||||
@@ -530,26 +549,117 @@ export default function WatchRoomMusicPage() {
|
|||||||
|
|
||||||
{song ? (
|
{song ? (
|
||||||
<>
|
<>
|
||||||
<div className="md:hidden flex items-center justify-center gap-2">
|
<div className="md:hidden">
|
||||||
<button
|
<div className="mx-auto flex w-full max-w-[430px] flex-col gap-3">
|
||||||
type="button"
|
<div ref={mobileVolumeControlRef} className={`rounded-2xl border px-3 py-3 ${isDark ? 'border-white/10 bg-black/20' : 'border-zinc-200 bg-white/80 shadow-sm'}`}>
|
||||||
onClick={() => setMobilePanel('cover')}
|
<div className="flex items-center gap-3">
|
||||||
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
|
||||||
>
|
type="button"
|
||||||
封面
|
onClick={() => setMobilePanel(showCoverPanel ? 'lyrics' : 'cover')}
|
||||||
</button>
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
<button
|
>
|
||||||
type="button"
|
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-zinc-800">
|
||||||
onClick={() => setMobilePanel('lyrics')}
|
{song.pic ? (
|
||||||
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'}`}
|
<img src={song.pic} alt={song.name} className="h-full w-full object-cover" />
|
||||||
>
|
) : (
|
||||||
歌词
|
<div className="flex h-full w-full items-center justify-center text-base text-zinc-500">♪</div>
|
||||||
</button>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">{song.name}</div>
|
||||||
|
<div className={`truncate text-xs ${isDark ? 'text-zinc-500' : 'text-zinc-600'}`}>{song.artist}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVolumeSlider((prev) => !prev)}
|
||||||
|
className={`relative 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>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCoverPanel ? (
|
||||||
|
<div className={`rounded-2xl border px-4 py-4 ${isDark ? 'border-white/10 bg-black/20' : 'border-zinc-200 bg-white/80 shadow-sm'}`}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<VinylTurntable song={song} isPlaying={Boolean(state?.isPlaying)} />
|
||||||
|
<AudioSpectrumCanvas bars={bars} compact volume={volume} />
|
||||||
|
<div className="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={mobileLyricsContainerRef}
|
||||||
|
className={`rounded-2xl border px-4 py-4 ${isDark ? 'border-white/10 bg-black/20' : 'border-zinc-200 bg-white/80 shadow-sm'}`}
|
||||||
|
>
|
||||||
|
<div className="max-h-[64vh] overflow-y-auto px-1">
|
||||||
|
{lyrics.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 min-h-[48vh] items-center justify-center ${isDark ? 'text-zinc-500' : 'text-zinc-400'}`}>暂无歌词</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<AudioSpectrumCanvas bars={bars} compact volume={volume} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 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>
|
</div>
|
||||||
|
|
||||||
<div className="grid flex-1 gap-4 md:grid-cols-[420px_minmax(0,1fr)]">
|
<div className="hidden md:grid flex-1 gap-4 md:grid-cols-[420px_minmax(0,1fr)]">
|
||||||
<div className={`${showCoverPanel ? 'block' : 'hidden'} min-w-0 md:block`}>
|
<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 ref={desktopVolumeControlRef} 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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -596,7 +706,7 @@ export default function WatchRoomMusicPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={lyricsContainerRef}
|
ref={desktopLyricsContainerRef}
|
||||||
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'}`}
|
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 ? (
|
{lyrics.length > 0 ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// WatchRoom 全局状态管理 Provider
|
// WatchRoom 全局状态管理 Provider
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||||
@@ -10,7 +10,7 @@ import Toast, { ToastProps } from '@/components/Toast';
|
|||||||
|
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
|
||||||
import type { ChatMessage, Member, MusicState, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
|
import type { ChatMessage, Member, MusicSyncState, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
|
||||||
|
|
||||||
// Import type from watch-room-socket
|
// Import type from watch-room-socket
|
||||||
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
||||||
@@ -60,12 +60,12 @@ interface WatchRoomContextType {
|
|||||||
changeLiveChannel: (state: any) => void;
|
changeLiveChannel: (state: any) => void;
|
||||||
startScreenShare: (state: ScreenState) => void;
|
startScreenShare: (state: ScreenState) => void;
|
||||||
stopScreenShare: () => void;
|
stopScreenShare: () => void;
|
||||||
changeMusic: (state: MusicState) => void;
|
changeMusic: (state: MusicSyncState) => void;
|
||||||
updateMusicState: (state: MusicState) => void;
|
updateMusicState: (state: MusicSyncState) => void;
|
||||||
updateMusicQueue: (state: MusicState) => void;
|
updateMusicQueue: (state: MusicSyncState) => void;
|
||||||
playMusic: (state: MusicState) => void;
|
playMusic: (state: MusicSyncState) => void;
|
||||||
pauseMusic: (state: MusicState) => void;
|
pauseMusic: (state: MusicSyncState) => void;
|
||||||
seekMusic: (state: MusicState) => void;
|
seekMusic: (state: MusicSyncState) => void;
|
||||||
clearRoomState: () => void;
|
clearRoomState: () => void;
|
||||||
|
|
||||||
// 重连
|
// 重连
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
LiveState,
|
LiveState,
|
||||||
Member,
|
Member,
|
||||||
MusicState,
|
MusicSyncState,
|
||||||
PlayState,
|
PlayState,
|
||||||
Room,
|
Room,
|
||||||
RoomType,
|
RoomType,
|
||||||
@@ -335,7 +335,7 @@ export function useWatchRoom(
|
|||||||
}, [isOwner]);
|
}, [isOwner]);
|
||||||
|
|
||||||
const changeMusic = useCallback(
|
const changeMusic = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ export function useWatchRoom(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateMusicState = useCallback(
|
const updateMusicState = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
@@ -355,17 +355,17 @@ export function useWatchRoom(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateMusicQueue = useCallback(
|
const updateMusicQueue = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
sock.emit('music:queue', state);
|
sock.emit('music:update', state);
|
||||||
},
|
},
|
||||||
[isOwner]
|
[isOwner]
|
||||||
);
|
);
|
||||||
|
|
||||||
const playMusic = useCallback(
|
const playMusic = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ export function useWatchRoom(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pauseMusic = useCallback(
|
const pauseMusic = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ export function useWatchRoom(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const seekMusic = useCallback(
|
const seekMusic = useCallback(
|
||||||
(state: MusicState) => {
|
(state: MusicSyncState) => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !isOwner) return;
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ export function useWatchRoom(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMusicState = (state: MusicState) => {
|
const handleMusicState = (state: MusicSyncState) => {
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,6 +575,11 @@ export class WatchRoomServer {
|
|||||||
|
|
||||||
this.rooms.forEach((room, roomId) => {
|
this.rooms.forEach((room, roomId) => {
|
||||||
const timeSinceHeartbeat = now - room.lastOwnerHeartbeat;
|
const timeSinceHeartbeat = now - room.lastOwnerHeartbeat;
|
||||||
|
const isScreenSharing = room.currentState?.type === 'screen' && room.currentState.status === 'sharing';
|
||||||
|
|
||||||
|
if (isScreenSharing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果房主心跳超过30秒,清除播放状态
|
// 如果房主心跳超过30秒,清除播放状态
|
||||||
if (timeSinceHeartbeat > clearStateTimeout && room.currentState !== null) {
|
if (timeSinceHeartbeat > clearStateTimeout && room.currentState !== null) {
|
||||||
|
|||||||
+15
-16
@@ -11,7 +11,7 @@ export interface Room {
|
|||||||
ownerName: string;
|
ownerName: string;
|
||||||
ownerToken: string; // 房主令牌,用于重连时验证身份
|
ownerToken: string; // 房主令牌,用于重连时验证身份
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
currentState: PlayState | LiveState | ScreenState | MusicState | null;
|
currentState: PlayState | LiveState | ScreenState | MusicSyncState | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastOwnerHeartbeat: number;
|
lastOwnerHeartbeat: number;
|
||||||
}
|
}
|
||||||
@@ -65,11 +65,10 @@ export interface MusicQueueItem {
|
|||||||
durationText?: string;
|
durationText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MusicState {
|
export interface MusicSyncState {
|
||||||
type: 'music';
|
type: 'music';
|
||||||
queue: MusicQueueItem[];
|
|
||||||
currentIndex: number;
|
|
||||||
song: MusicQueueItem;
|
song: MusicQueueItem;
|
||||||
|
nextSong: MusicQueueItem | null;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
quality: string;
|
quality: string;
|
||||||
@@ -114,12 +113,12 @@ export interface ServerToClientEvents {
|
|||||||
'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
|
'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
|
||||||
'music:change': (state: MusicState) => void;
|
'music:change': (state: MusicSyncState) => void;
|
||||||
'music:update': (state: MusicState) => void;
|
'music:update': (state: MusicSyncState) => void;
|
||||||
'music:play': (state: MusicState) => void;
|
'music:play': (state: MusicSyncState) => void;
|
||||||
'music:pause': (state: MusicState) => void;
|
'music:pause': (state: MusicSyncState) => void;
|
||||||
'music:seek': (state: MusicState) => void;
|
'music:seek': (state: MusicSyncState) => void;
|
||||||
'music:queue': (state: MusicState) => void;
|
'music:queue': (state: MusicSyncState) => void;
|
||||||
'chat:message': (message: ChatMessage) => void;
|
'chat:message': (message: ChatMessage) => void;
|
||||||
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
@@ -169,12 +168,12 @@ export interface ClientToServerEvents {
|
|||||||
'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
|
'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
|
'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
|
'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
|
||||||
'music:change': (state: MusicState) => void;
|
'music:change': (state: MusicSyncState) => void;
|
||||||
'music:update': (state: MusicState) => void;
|
'music:update': (state: MusicSyncState) => void;
|
||||||
'music:play': (state: MusicState) => void;
|
'music:play': (state: MusicSyncState) => void;
|
||||||
'music:pause': (state: MusicState) => void;
|
'music:pause': (state: MusicSyncState) => void;
|
||||||
'music:seek': (state: MusicState) => void;
|
'music:seek': (state: MusicSyncState) => void;
|
||||||
'music:queue': (state: MusicState) => void;
|
'music:queue': (state: MusicSyncState) => void;
|
||||||
|
|
||||||
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user