基于 Document PiP API 实现视频小窗及状态同步
提示:本文演示如何利用
Document Picture-in-Picture API实现网页视频的小窗播放功能,并支持主窗口与画中画窗口之间的双向状态同步。
1. 前言
在视频观看场景中,用户往往需要在播放的同时处理其他任务。传统的画中画(PiP)功能通常仅支持视频元素本身,无法携带自定义控件或交互逻辑。随着 Chrome 116+ 对 Document Picture-in-Picture API 的支持,我们能够将整个文档内容移入独立的小窗口中,并在其中保留完整的 HTML 结构和 JavaScript 上下文。
本文将带你实现一个完整案例:在主页面点击按钮后开启小窗模式,并确保主窗口与小窗在播放、暂停、进度、音量等状态上实时同步。

2. 为什么使用 Document PiP API
相比传统的 <video> 标签自带的 PiP 功能,新的 Document PiP 具有以下优势:
- 完整 HTML 支持:小窗内可包含按钮、进度条、字幕等任意 DOM 元素。
- 无缝集成:主页面与小窗共享 JavaScript 上下文,便于通信。
- 尺寸灵活:可自定义小窗的宽度和高度。
- 双向通信:主窗口与小窗可实时同步状态,提升用户体验。
3. 完整代码案例
以下示例包含 CSS 样式、HTML 结构及核心 JavaScript 逻辑。请确保本地有一个名为 1.mp4 的视频文件,或修改代码中的 src 属性指向有效的视频资源。
3.1 可直接复用运行代码
CSS 样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
header {
background: linear-gradient(to right, #1a2a6c, #b21f1f);
color: white;
padding: 25px 40px;
text-align: center;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
max-width: 700px;
margin: 0 auto;
}
.content {
display: flex;
padding: 30px;
gap: 30px;
}
.video-section {
flex: 3;
background: #f8f9fa;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.video-container {
position: relative;
padding-top: 56.25%; /* 16:9 Aspect Ratio */
background: #000;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.video-controls {
display: flex;
padding: 15px;
gap: 10px;
background: #e9ecef;
}
button {
background: #1a2a6c;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
button:hover {
background: #0d1a4d;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.info-section {
flex: 2;
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
h2 {
color: #1a2a6c;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e9ecef;
}
.feature-list {
margin: 20px 0;
}
.feature {
display: flex;
align-items: flex-start;
margin-bottom: 15px;
}
.feature-icon {
background: #1a2a6c;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
flex-shrink: 0;
}
.pip-window {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
height: 200px;
background: black;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
z-index: 1000;
display: none;
}
.pip-window video {
width: 100%;
height: 100%;
object-fit: cover;
}
.pip-controls {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
}
.pip-window:hover .pip-controls {
opacity: 1;
}
.status {
padding: 15px;
background: #e9ecef;
border-radius: 8px;
margin-top: 20px;
font-family: monospace;
}
.browser-support {
margin-top: 30px;
padding: 20px;
background: #fff8e1;
border-radius: 8px;
border-left: 4px solid #ffc107;
}
.support-list {
display: flex;
gap: 15px;
margin-top: 15px;
flex-wrap: wrap;
}
.browser {
display: flex;
align-items: center;
gap: 8px;
}
.supported {
color: #28a745;
}
.unsupported {
color: #dc3545;
}
@media (max-width: 900px) {
.content {
flex-direction: column;
}
}
HTML 结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>视频小窗模式演示</title>
<link href="css/pip.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<div class="container">
<header>
<h1>视频小窗模式演示</h1>
<p class="subtitle">使用 Document Picture-in-Picture API 实现在其他内容上浮动播放视频</p>
</header>
<div class="content">
<div class="video-section">
<div class="video-container">
<video id="mainVideo" src="1.mp4" controls playsinline></video>
</div>
<div class="video-controls">
<button id="pipButton" title="开启小窗模式">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 3.5A1.5 1.5 0 0 1 1.5 2h13A1.5 1.5 0 0 1 16 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 12.5v-9zM1.5 3a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-9a.5.5 0 0 0-.5-.5h-13z"/>
<path d="M8 8.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-5a.5.5 0 0 1-.5-.5v-3z"/>
</svg> 开启小窗模式
</button>
<button id="fullscreenButton">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
</svg> 全屏
</button>
</div>
</div>
<div class="info-section">
<h2>Document Picture-in-Picture API</h2>
<div class="feature-list">
<div class="feature">
<div class="feature-icon">1</div>
<div>
<h3>任意 HTML 内容</h3>
<p>可以在小窗中显示视频控件、字幕等任意 HTML 元素</p>
</div>
</div>
<div class="feature">
<div class="feature-icon">2</div>
<div>
<h3>保持播放状态</h3>
<p>进入小窗模式时视频持续播放,不中断观看体验</p>
</div>
</div>
<div class="feature">
<div class="feature-icon">3</div>
<div>
<h3>双向同步</h3>
<p>主页面和小窗中的视频状态实时同步</p>
</div>
</div>
<div class="feature">
<div class="feature-icon">4</div>
<div>
<h3>自由调整</h3>
<p>用户可以调整小窗位置和大小,适应不同需求</p>
</div>
</div>
</div>
<div class="status">
<p>当前状态:<span id="statusText">等待操作</span></p>
<p>小窗状态:<span id="pipStatus">未激活</span></p>
</div>
<div class="browser-support">
<h3>浏览器支持情况</h3>
<div class="support-list">
<div class="browser"><span class="supported">Chrome 116+</span></div>
<div class="browser"><span class="unsupported">Firefox</span></div>
<div class="browser"><span class="unsupported">Edge</span></div>
<div class="browser"><span class="unsupported">Safari</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- 小窗模式容器 -->
<div id="pipContainer" class="pip-window">
<video id="pipVideo" src="1.mp4" controls></video>
<div class="pip-controls">
<button id="closePipButton" title="关闭小窗">关闭</button>
</div>
</div>
<script>
// 页面元素
const mainVideo = document.getElementById('mainVideo');
const pipVideo = document.getElementById('pipVideo');
const pipButton = document.getElementById('pipButton');
const fullscreenButton = document.getElementById('fullscreenButton');
const closePipButton = document.getElementById('closePipButton');
const pipContainer = document.getElementById('pipContainer');
const statusText = document.getElementById('statusText');
const pipStatus = document.getElementById('pipStatus');
// 检查浏览器支持情况
const isPipSupported = 'documentPictureInPicture' in window;
// 初始化页面
function init() {
updateStatus(isPipSupported ? "Document Picture-in-Picture API 可用" : "您的浏览器不支持 Document Picture-in-Picture API");
// 设置按钮状态
pipButton.disabled = !isPipSupported;
// 添加事件监听器
pipButton.addEventListener('click', togglePictureInPicture);
fullscreenButton.addEventListener('click', toggleFullscreen);
closePipButton.addEventListener('click', closePictureInPicture);
// 初始化视频源
pipVideo.src = mainVideo.src;
mainVideo.muted = true;
pipVideo.muted = true;
}
// 更新状态显示
function updateStatus(message) {
statusText.textContent = message;
}
// 更新 PIP 状态显示
function updatePipStatus(message) {
pipStatus.textContent = message;
}
// 切换小窗模式
async function togglePictureInPicture() {
if (!isPipSupported) return;
// 如果小窗已打开,则关闭
if (window.documentPictureInPicture.window) {
await closePictureInPicture();
return;
}
try {
// 打开小窗
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 300,
});
// 设置小窗标题
pipWindow.document.title = "视频小窗播放";
// 添加样式
const style = document.createElement('style');
style.textContent = `
body { margin: 0; background: black; height: 100vh; overflow: hidden; }
video { width: 100%; height: 100%; object-fit: contain; }
`;
pipWindow.document.head.appendChild(style);
// 添加视频元素到小窗
pipWindow.document.body.appendChild(pipVideo);
// 同步播放状态
pipVideo.currentTime = mainVideo.currentTime;
if (!mainVideo.paused) {
await pipVideo.play();
} else {
pipVideo.pause();
}
// 处理小窗关闭事件
pipWindow.addEventListener('pagehide', () => {
// 将视频元素移回主文档
pipContainer.appendChild(pipVideo);
pipContainer.style.display = 'none';
updatePipStatus("已关闭");
});
// 显示小窗容器(用于样式)
pipContainer.style.display = 'block';
updateStatus("小窗模式已激活");
updatePipStatus("运行中");
// 同步播放状态
mainVideo.addEventListener('timeupdate', syncVideoTime);
pipVideo.addEventListener('timeupdate', syncVideoTime);
// 同步播放/暂停状态
mainVideo.addEventListener('play', () => pipVideo.play());
mainVideo.addEventListener('pause', () => pipVideo.pause());
pipVideo.addEventListener('play', () => mainVideo.play());
pipVideo.addEventListener('pause', () => mainVideo.pause());
// 同步音量
mainVideo.addEventListener('volumechange', syncVolume);
pipVideo.addEventListener('volumechange', syncVolume);
} catch (error) {
updateStatus(`错误:${error.message}`);
console.error(error);
}
}
// 同步视频播放时间
function syncVideoTime() {
// 避免循环同步
if (Math.abs(mainVideo.currentTime - pipVideo.currentTime) > 0.5) {
if (this === mainVideo) {
pipVideo.currentTime = mainVideo.currentTime;
} else {
mainVideo.currentTime = pipVideo.currentTime;
}
}
}
// 同步音量
function syncVolume() {
if (this === mainVideo) {
pipVideo.volume = mainVideo.volume;
pipVideo.muted = mainVideo.muted;
} else {
mainVideo.volume = pipVideo.volume;
mainVideo.muted = pipVideo.muted;
}
}
// 关闭小窗模式
async function closePictureInPicture() {
if (window.documentPictureInPicture.window) {
window.documentPictureInPicture.window.close();
}
// 移除事件监听器
mainVideo.removeEventListener('timeupdate', syncVideoTime);
pipVideo.removeEventListener('timeupdate', syncVideoTime);
mainVideo.removeEventListener('volumechange', syncVolume);
pipVideo.removeEventListener('volumechange', syncVolume);
// 将视频移回原始位置
pipContainer.appendChild(pipVideo);
pipContainer.style.display = 'none';
updateStatus("小窗模式已关闭");
updatePipStatus("未激活");
}
// 切换全屏模式
function toggleFullscreen() {
if (!document.fullscreenElement) {
if (mainVideo.requestFullscreen) {
mainVideo.requestFullscreen();
} else if (mainVideo.webkitRequestFullscreen) {
mainVideo.webkitRequestFullscreen();
} else if (mainVideo.msRequestFullscreen) {
mainVideo.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
3.2 演示效果
运行上述代码后,点击'开启小窗模式'按钮,即可看到视频浮于桌面之上。此时主窗口可以最小化或浏览其他页面,小窗依然保持播放。

4. 案例核心原理与流程
4.1 代码流程
- 创建窗口:用户点击按钮后,调用
documentPictureInPicture.requestWindow({ width, height })创建一个新的PiP窗口对象。 - 移动节点:将包含
<video>的 DOM 节点移动到小窗口中,使其成为新窗口的主体内容。 - 状态同步:在两个窗口里通过事件监听器同步播放、暂停、音量、当前时间等状态。
- 清理恢复:窗口关闭时,通过监听
pagehide事件或按钮将 DOM 恢复到主窗口。
4.2 状态同步机制
为了保证体验一致,我们需要监听关键事件:
- timeupdate:同步播放进度,注意设置阈值防止死循环。
- play / pause:同步播放状态。
- volumechange:同步音量设置。
- pagehide:检测小窗关闭,执行清理工作。
5. 结语
Document Picture-in-Picture API 为开发者提供了强大的工具来创建更灵活的视频观看体验。虽然目前浏览器支持主要集中在 Chrome 116+,但随着标准的发展,相信它将成为视频播放页面的标配功能。
本方案相比传统 PiP 能实现更强的可定制化,适用于自定义播放器场景。如果你在实践过程中有任何疑问或更好的扩展思路,欢迎在评论区留言讨论。


