跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaScript大前端

前端实现视频画中画功能 - 主页面与小窗同步控制

基于 Document Picture-in-Picture API 实现网页视频浮动播放。通过创建独立小窗口并同步主窗口状态(播放、暂停、进度、音量),解决多任务场景下的观看体验问题。相比传统 PiP,支持自定义控件与双向通信,适用于视频会议、在线课程等场景。

ByteFlow发布于 2026/3/28更新于 2026/10/2589 浏览
前端实现视频画中画功能 - 主页面与小窗同步控制

前端实现视频画中画功能 - 主页面与小窗同步控制

在网页开发中,我们常遇到需要在观看视频的同时处理其他任务的需求。B 站等平台的画中画功能允许视频以小窗口形式悬浮在其他内容之上。随着 Chrome 116+ 对 Document Picture-in-Picture API 的支持,我们可以将整个文档内容移入独立小窗,并自定义控件与交互逻辑。

本文分享一个完整案例:实现主页面与小窗的视频状态同步(播放、暂停、进度、音量),提升多任务场景下的用户体验。

为什么使用 Document PiP API

传统的 Picture-in-Picture API 仅支持将 <video> 元素本身移出,无法携带自定义 UI 或脚本上下文。新的 Document Picture-in-Picture API 优势在于:

  • 完整 HTML 支持:小窗内可包含按钮、进度条等任意交互元素。
  • 无缝集成:与原始页面共享 JavaScript 上下文,便于双向通信。
  • 尺寸灵活:可自定义小窗宽高。
  • 双向同步:主页面与小窗实时同步状态。

画中画效果示意

开启画中画后,即使收缩主窗口,小窗口依然保留在桌面上。

收缩主窗口

核心代码实现

为了演示方便,这里提供可直接运行的单文件示例。你需要准备一个本地视频文件(如 1.mp4)替换代码中的 src。

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">
                            <svg width="24" height="24" fill="#4285F4" viewBox="0 0 24 24"><path d="M12 15.6l-3.9 2.3 1-4.3-3.2-2.9 4.3-.4L12 6.5l1.8 4.1 4.3.4-3.2 2.9 1 4.3z"/></svg>
                            <span class="supported">Chrome 108+</span>
                        </div>
                        <div class="browser">
                            <svg width="24" height="24" fill="#FF9500" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17.2 3H6.8l-5.2 9 5.2 9h10.4l5.2-9-5.2-9zm-1.15 16h-8.1l-4.04-7 4.04-7h8.09l4.04 7-4.03 7z"/></svg>
                            <span class="unsupported">Firefox</span>
                        </div>
                        <div class="browser">
                            <svg width="24" height="24" fill="#0078D7" viewBox="0 0 24 24"><path d="M0 0v24h24V0H0zm22 22H2V2h20v20z"/><path d="M12 12l-4 4 1.4 1.4 2.6-2.6 2.6 2.6 1.4-1.4z"/></svg>
                            <span class="unsupported">Edge</span>
                        </div>
                        <div class="browser">
                            <svg width="24" height="24" fill="#000000" viewBox="0 0 24 24"><path d="M18.7 4.3c-1.2-1.2-2.9-1.9-4.7-1.9H5C3.3 2.4 2 3.7 2 5.4v13.1c0 1.8 1.5 3.2 3.3 3.2H19c1.8 0 3.2-1.4 3.2-3.2V9c0-1.8-.7-3.5-1.9-4.7h-.6zM19 20.5H5.3c-.9 0-1.7-.7-1.7-1.7V5.4c0-.9.8-1.7 1.7-1.7h9c.9 0 1.7.7 1.7 1.7v3.9h3.9c.9 0 1.7.8 1.7 1.7v7.9c0 .9-.7 1.7-1.6 1.7z"/></svg>
                            <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>

核心原理与流程

代码流程

  1. 用户点击按钮,调用 documentPictureInPicture.requestWindow() 创建 PiP 窗口。
  2. 将包含 <video> 的 DOM 节点移动到小窗口中。
  3. 在两个窗口里同步播放、暂停、音量、当前时间等状态。
  4. 窗口关闭时,通过监听 pagehide 或按钮将 DOM 恢复到主窗口。

状态同步机制

关键在于事件监听器的双向绑定。当主窗口触发 play 事件时,强制小窗口也 play;反之亦然。对于 timeupdate,我们增加了一个阈值判断(0.5 秒),防止因网络波动或精度问题导致的无限循环同步。

关键事件处理

  • timeupdate:同步播放进度,需防抖处理。
  • play/pause:同步播放状态,确保两端一致。
  • volumechange:同步音量设置及静音状态。
  • pagehide:检测小窗关闭,执行资源回收。

结语

Document Picture-in-Picture API 为开发者提供了强大的工具来创建更灵活的视频观看体验。虽然目前浏览器支持有限(主要依赖 Chrome 116+),但随着标准的发展,相信它将成为视频播放页面的标配功能。

本方案相比传统 PiP 能实现更强的可定制化,适用于自定义播放器场景。如果你在实践过程中有任何疑问,欢迎交流探讨。

目录

  1. 前端实现视频画中画功能 - 主页面与小窗同步控制
  2. 为什么使用 Document PiP API
  3. 核心代码实现
  4. CSS 样式
  5. HTML 结构
  6. 核心原理与流程
  7. 代码流程
  8. 状态同步机制
  9. 关键事件处理
  10. 结语

更多推荐文章

查看全部
  • FL-NAS: 基于大语言模型的资源受限设备 NAS 公平性研究
  • SkyWalking 多语言探针现状:.NET / C++ / Lua 深度解析
  • Buzz 离线语音转文字工具安装与使用指南
  • 基于昇腾 NPU 部署 Mistral-7B-Instruct-v0.2 模型的技术实践
  • 基于DamoFD-0.5G的AR虚拟试妆系统
  • MySQL 数据类型详解:从数值到字符串的实战指南
  • C++ unordered 容器使用与模拟实现笔记
  • Android项目框架搭建与模块化设计
  • Scan2CAD 教程:AI 驱动的扫描图像转 CAD 模型
  • OpenClaw 配置 GLM 实现免费 AI 联网搜索
  • 前端安全实战:构建防御体系,规避常见漏洞
  • Java 空值安全性注解系统
  • GraphHopper 实战:利用开源引擎优化城市物流配送
  • Linux 系统安装与配置 Ollama 本地大模型部署指南
  • 主流人体算法对比:Mask2Former-Parsing 为何超越 Deeplabv3+
  • 算法实战:一维与二维前缀和模板详解
  • PID 控制原理、整定方法与 C 语言实现
  • 基于 SpringBoot 的家庭财务管理系统设计与实现
  • 从 0 到 1 打造 RISC-V 智能家居中控:硬件固件通信全链路实战
  • C++ 临时对象的产生与避免

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online