窗中窗

This commit is contained in:
mtvpls
2026-02-03 22:46:02 +08:00
parent b48c573e28
commit 33420f02e5
2 changed files with 505 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/db.client';
import AddToPlaylistModal from '@/components/AddToPlaylistModal';
import Toast, { ToastProps } from '@/components/Toast';
import LyricsPiPWindow from '@/components/LyricsPiPWindow';
interface Song {
id: string;
@@ -55,6 +56,16 @@ interface DbRecord {
pic?: string;
}
// 扩展 Window 接口以支持 Document PiP API
declare global {
interface Window {
documentPictureInPicture?: {
requestWindow: (options: { width: number; height: number }) => Promise<Window>;
window: Window | null;
};
}
}
export default function MusicPage() {
const router = useRouter();
const [currentSource, setCurrentSource] = useState<'netease' | 'qq' | 'kuwo'>('netease');
@@ -112,6 +123,11 @@ export default function MusicPage() {
onCancel: () => {},
});
// PiP 相关状态
const [showPiPLyrics, setShowPiPLyrics] = useState(false);
const [pipOpacity, setPipOpacity] = useState(0.9);
const [pipMinimized, setPipMinimized] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const lyricsContainerRef = useRef<HTMLDivElement>(null);
const lastSaveTimeRef = useRef<number>(0);
@@ -293,6 +309,36 @@ export default function MusicPage() {
initializePlayState();
}, []);
// 恢复 PiP 偏好设置
useEffect(() => {
const savedOpacity = localStorage.getItem('lyricsPiPOpacity');
const savedMinimized = localStorage.getItem('lyricsPiPMinimized');
if (savedOpacity) setPipOpacity(parseFloat(savedOpacity));
if (savedMinimized) setPipMinimized(savedMinimized === 'true');
}, []);
// 监听来自 PiP 窗口的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
switch (event.data.type) {
case 'PIP_OPACITY_CHANGE':
setPipOpacity(event.data.opacity);
localStorage.setItem('lyricsPiPOpacity', event.data.opacity.toString());
break;
case 'PIP_MINIMIZED_CHANGE':
setPipMinimized(event.data.minimized);
localStorage.setItem('lyricsPiPMinimized', event.data.minimized.toString());
break;
case 'PIP_CLOSE':
setShowPiPLyrics(false);
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// 监听播放状态变化,自动保存
useEffect(() => {
if (currentSong) {
@@ -1098,6 +1144,29 @@ export default function MusicPage() {
}
};
// PiP 窗口管理
const togglePiPLyrics = () => {
if (!('documentPictureInPicture' in window)) {
setToast({
message: '您的浏览器不支持画中画功能,请使用 Chrome 116+ 版本',
type: 'error',
});
// 降级方案:打开全屏歌词
setShowLyrics(true);
return;
}
if (!currentSong) {
setToast({
message: '请先播放歌曲',
type: 'info',
});
return;
}
setShowPiPLyrics(!showPiPLyrics);
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const getQualityLabel = () => {
@@ -1861,6 +1930,24 @@ export default function MusicPage() {
</div>
</div>
</div>
{/* PiP 歌词按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
togglePiPLyrics();
}}
className={`transition-colors ${
showPiPLyrics
? 'text-green-500 hover:text-green-400'
: 'text-zinc-500 hover:text-white'
}`}
title={showPiPLyrics ? '关闭画中画歌词' : '画中画歌词'}
disabled={!currentSong}
>
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/>
</svg>
</button>
{/* 添加到歌单按钮 */}
<button
onClick={(e) => {
@@ -2280,6 +2367,28 @@ export default function MusicPage() {
</div>,
document.body
)}
{/* PiP Lyrics Window */}
{showPiPLyrics && (
<LyricsPiPWindow
currentSong={currentSong}
lyrics={lyrics}
currentLyricIndex={currentLyricIndex}
isPlaying={isPlaying}
currentTime={currentTime}
opacity={pipOpacity}
minimized={pipMinimized}
onOpacityChange={(opacity) => {
setPipOpacity(opacity);
localStorage.setItem('lyricsPiPOpacity', opacity.toString());
}}
onMinimizedChange={(minimized) => {
setPipMinimized(minimized);
localStorage.setItem('lyricsPiPMinimized', minimized.toString());
}}
onClose={() => setShowPiPLyrics(false)}
/>
)}
</>
</div>
);

View File

@@ -0,0 +1,396 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom/client';
interface Song {
id: string;
name: string;
artist: string;
album?: string;
pic?: string;
platform: 'netease' | 'qq' | 'kuwo';
}
interface LyricLine {
time: number;
text: string;
}
interface LyricsPiPWindowProps {
currentSong: Song | null;
lyrics: LyricLine[];
currentLyricIndex: number;
isPlaying: boolean;
currentTime: number;
opacity: number;
minimized: boolean;
onOpacityChange: (opacity: number) => void;
onMinimizedChange: (minimized: boolean) => void;
onClose: () => void;
}
interface PiPLyricsContentProps {
currentSong: Song | null;
lyrics: LyricLine[];
currentLyricIndex: number;
opacity: number;
minimized: boolean;
onOpacityChange: (opacity: number) => void;
onMinimizedChange: (minimized: boolean) => void;
onClose: () => void;
}
// PiP 窗口内容组件
const PiPLyricsContent = ({
currentSong,
lyrics,
currentLyricIndex,
opacity,
minimized,
onOpacityChange,
onMinimizedChange,
onClose,
}: PiPLyricsContentProps) => {
const lyricsContainerRef = useRef<HTMLDivElement>(null);
// 自动滚动到当前歌词
useEffect(() => {
if (lyricsContainerRef.current && currentLyricIndex >= 0 && !minimized) {
const container = lyricsContainerRef.current;
const currentLine = container.children[currentLyricIndex] as HTMLElement;
if (currentLine) {
const containerHeight = container.clientHeight;
const lineTop = currentLine.offsetTop;
const lineHeight = currentLine.clientHeight;
const scrollTop = lineTop - containerHeight / 2 + lineHeight / 2;
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
}
}
}, [currentLyricIndex, minimized]);
return (
<div
className="pip-container"
style={{
backgroundColor: `rgba(0, 0, 0, ${opacity})`,
height: '100vh',
display: 'flex',
flexDirection: 'column',
padding: '16px',
color: 'white',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
{/* 头部:歌曲信息 + 控制按钮 */}
<div
className="pip-header"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
gap: '8px',
}}
>
<div
style={{
fontSize: '12px',
opacity: 0.7,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{currentSong ? `${currentSong.name} - ${currentSong.artist}` : '暂无播放'}
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexShrink: 0 }}>
{/* 透明度滑块 */}
<input
type="range"
min="0.3"
max="1"
step="0.1"
value={opacity}
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
style={{ width: '60px', cursor: 'pointer' }}
title={`透明度: ${Math.round(opacity * 100)}%`}
/>
{/* 最小化按钮 */}
<button
onClick={() => onMinimizedChange(!minimized)}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
cursor: 'pointer',
fontSize: '11px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}}
title={minimized ? '展开' : '最小化'}
>
{minimized ? '展开' : '最小化'}
</button>
{/* 关闭按钮 */}
<button
onClick={onClose}
style={{
background: 'rgba(239, 68, 68, 0.8)',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
color: 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
transition: 'background 0.2s',
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.8)';
}}
title="关闭"
>
×
</button>
</div>
</div>
{/* 歌词内容 */}
{minimized ? (
// 最小化模式:仅显示当前歌词
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
textAlign: 'center',
padding: '0 16px',
lineHeight: 1.5,
}}
>
{lyrics.length > 0 && currentLyricIndex >= 0
? lyrics[currentLyricIndex]?.text || '♪'
: currentSong
? '暂无歌词'
: '请播放歌曲'}
</div>
) : (
// 完整模式:显示所有歌词
<div
ref={lyricsContainerRef}
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
paddingTop: '20px',
paddingBottom: '20px',
}}
>
{lyrics.length > 0 ? (
lyrics.map((line, index) => (
<div
key={index}
style={{
padding: '8px 0',
textAlign: 'center',
fontSize: index === currentLyricIndex ? '16px' : '14px',
opacity: index === currentLyricIndex ? 1 : 0.5,
color: index === currentLyricIndex ? '#22c55e' : 'white',
transition: 'all 0.3s ease',
fontWeight: index === currentLyricIndex ? 'bold' : 'normal',
}}
>
{line.text}
</div>
))
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
opacity: 0.5,
}}
>
{currentSong ? '暂无歌词' : '请播放歌曲'}
</div>
)}
</div>
)}
</div>
);
};
// 复制样式表到 PiP 窗口
const copyStylesToPiPWindow = (pipWin: Window) => {
const styleSheets = Array.from(document.styleSheets);
styleSheets.forEach((styleSheet) => {
try {
const cssRules = Array.from(styleSheet.cssRules)
.map((rule) => rule.cssText)
.join('');
const style = pipWin.document.createElement('style');
style.textContent = cssRules;
pipWin.document.head.appendChild(style);
} catch (e) {
// 跨域样式表使用 link 标签
if ((styleSheet as any).href) {
const link = pipWin.document.createElement('link');
link.rel = 'stylesheet';
link.href = (styleSheet as any).href;
pipWin.document.head.appendChild(link);
}
}
});
};
// 主组件:管理 PiP 窗口
export default function LyricsPiPWindow({
currentSong,
lyrics,
currentLyricIndex,
isPlaying,
currentTime,
opacity,
minimized,
onOpacityChange,
onMinimizedChange,
onClose,
}: LyricsPiPWindowProps) {
const pipWindowRef = useRef<Window | null>(null);
const rootRef = useRef<ReactDOM.Root | null>(null);
// 渲染 PiP 内容
const renderPiPContent = (pipWin: Window) => {
const container = pipWin.document.createElement('div');
container.id = 'pip-lyrics-root';
pipWin.document.body.appendChild(container);
// 设置 body 样式
pipWin.document.body.style.margin = '0';
pipWin.document.body.style.padding = '0';
pipWin.document.body.style.overflow = 'hidden';
// 使用 ReactDOM 渲染组件到 PiP 窗口
const root = ReactDOM.createRoot(container);
rootRef.current = root;
root.render(
<PiPLyricsContent
currentSong={currentSong}
lyrics={lyrics}
currentLyricIndex={currentLyricIndex}
opacity={opacity}
minimized={minimized}
onOpacityChange={(newOpacity) => {
window.postMessage({ type: 'PIP_OPACITY_CHANGE', opacity: newOpacity }, '*');
}}
onMinimizedChange={(newMinimized) => {
window.postMessage({ type: 'PIP_MINIMIZED_CHANGE', minimized: newMinimized }, '*');
}}
onClose={() => {
window.postMessage({ type: 'PIP_CLOSE' }, '*');
}}
/>
);
};
// 更新 PiP 内容
useEffect(() => {
if (pipWindowRef.current && !pipWindowRef.current.closed && rootRef.current) {
rootRef.current.render(
<PiPLyricsContent
currentSong={currentSong}
lyrics={lyrics}
currentLyricIndex={currentLyricIndex}
opacity={opacity}
minimized={minimized}
onOpacityChange={(newOpacity) => {
window.postMessage({ type: 'PIP_OPACITY_CHANGE', opacity: newOpacity }, '*');
}}
onMinimizedChange={(newMinimized) => {
window.postMessage({ type: 'PIP_MINIMIZED_CHANGE', minimized: newMinimized }, '*');
}}
onClose={() => {
window.postMessage({ type: 'PIP_CLOSE' }, '*');
}}
/>
);
}
}, [currentSong, lyrics, currentLyricIndex, opacity, minimized]);
// 打开 PiP 窗口
useEffect(() => {
const openPiPWindow = async () => {
if (!('documentPictureInPicture' in window)) {
console.error('浏览器不支持 Document Picture-in-Picture API');
return;
}
try {
const pipWin = await (window as any).documentPictureInPicture.requestWindow({
width: 400,
height: 300,
});
pipWindowRef.current = pipWin;
// 复制样式表到 PiP 窗口
copyStylesToPiPWindow(pipWin);
// 监听窗口关闭
pipWin.addEventListener('pagehide', () => {
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
pipWindowRef.current = null;
onClose();
});
// 渲染内容
renderPiPContent(pipWin);
} catch (error) {
console.error('打开画中画窗口失败:', error);
onClose();
}
};
openPiPWindow();
// 清理函数
return () => {
if (pipWindowRef.current && !pipWindowRef.current.closed) {
pipWindowRef.current.close();
}
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
};
}, []); // 只在组件挂载时执行一次
return null; // 此组件不渲染任何内容到主窗口
}