documentPictureInPicture API 教程
documentPictureInPicture API 教程
概述
documentPictureInPicture API 是一个强大的 Web API,允许开发者创建始终置顶的浮动窗口,用户可以在其中放置任意的 HTML 内容。与传统的视频画中画不同,这个 API 支持完整的 DOM 内容,包括交互式元素、表单、按钮等。
浏览器支持
目前该 API 主要在基于 Chromium 的浏览器中支持:
- Chrome 116+
- Edge 116+
- Opera 102+
Firefox 和 Safari 暂时不支持此 API。
基本概念
什么是 documentPictureInPicture?
documentPictureInPicture 允许您:
- 创建一个独立的浮动窗口
- 在窗口中显示完整的 HTML 内容
- 保持窗口始终置顶
- 支持用户交互和 JavaScript 执行
主要用途
- 媒体播放器控制面板
- 聊天应用的浮动窗口
- 实时数据监控面板
- 工具栏和快捷操作面板
- 视频会议的参与者列表
API 参考
检查浏览器支持
if ('documentPictureInPicture' in window) {console.log('documentPictureInPicture API 支持');
} else {console.log('documentPictureInPicture API 不支持');
}
主要方法和属性
documentPictureInPicture.requestWindow(options)
创建一个新的画中画窗口。
参数:
-
options
(可选): 配置对象
width
: 窗口宽度(像素)height
: 窗口高度(像素)
返回值: Promise,解析为 Window 对象
documentPictureInPicture.window
返回当前打开的画中画窗口对象,如果没有则返回 null
。
事件
enter
事件
当画中画窗口打开时触发。
documentPictureInPicture.addEventListener('enter', (event) => {console.log('画中画窗口已打开', event.window);
});
基础用法
1. 创建简单的画中画窗口
async function openPictureInPicture() {try {// 检查浏览器支持if (!('documentPictureInPicture' in window)) {throw new Error('不支持 documentPictureInPicture API');}// 创建画中画窗口const pipWindow = await documentPictureInPicture.requestWindow({width: 400,height: 300});// 在窗口中添加内容pipWindow.document.body.innerHTML = `<div style="font-family: Arial, sans-serif; padding: 20px;"><h2>画中画窗口</h2><p>这是一个浮动的画中画窗口!</p><button onclick="window.close()">关闭窗口</button></div>`;console.log('画中画窗口已创建');} catch (error) {console.error('创建画中画窗口失败:', error);}
}
2. 复制现有元素到画中画窗口
async function moveElementToPiP(elementId) {try {const element = document.getElementById(elementId);if (!element) {throw new Error('未找到指定元素');}const pipWindow = await documentPictureInPicture.requestWindow({width: 500,height: 400});// 复制元素的样式const styles = Array.from(document.styleSheets).map(styleSheet => {try {return Array.from(styleSheet.cssRules).map(rule => rule.cssText).join('\n');} catch (e) {return '';}}).join('\n');// 创建样式标签const styleElement = pipWindow.document.createElement('style');styleElement.textContent = styles;pipWindow.document.head.appendChild(styleElement);// 移动元素到画中画窗口pipWindow.document.body.appendChild(element);// 监听窗口关闭事件,将元素移回原位置pipWindow.addEventListener('beforeunload', () => {document.body.appendChild(element);});} catch (error) {console.error('移动元素到画中画失败:', error);}
}
高级用法
1. 创建媒体播放器控制面板
class MediaControllerPiP {constructor(videoElement) {this.video = videoElement;this.pipWindow = null;}async openController() {try {this.pipWindow = await documentPictureInPicture.requestWindow({width: 400,height: 200});this.setupControllerUI();this.bindEvents();} catch (error) {console.error('打开控制面板失败:', error);}}setupControllerUI() {this.pipWindow.document.head.innerHTML = `<style>body {font-family: Arial, sans-serif;margin: 0;padding: 20px;background: #1a1a1a;color: white;}.controls {display: flex;align-items: center;gap: 10px;margin-bottom: 15px;}button {padding: 8px 16px;border: none;border-radius: 4px;background: #333;color: white;cursor: pointer;}button:hover {background: #555;}.progress {width: 100%;margin-top: 10px;}.time-display {font-size: 14px;margin-top: 10px;}</style>`;this.pipWindow.document.body.innerHTML = `<div class="media-controller"><h3>媒体控制面板</h3><div class="controls"><button id="playPause">播放/暂停</button><button id="stop">停止</button><button id="mute">静音</button></div><input type="range" id="progress" class="progress" min="0" max="100" value="0"><input type="range" id="volume" min="0" max="1" step="0.1" value="1"><div class="time-display" id="timeDisplay">00:00 / 00:00</div></div>`;}bindEvents() {const playPauseBtn = this.pipWindow.document.getElementById('playPause');const stopBtn = this.pipWindow.document.getElementById('stop');const muteBtn = this.pipWindow.document.getElementById('mute');const progressBar = this.pipWindow.document.getElementById('progress');const volumeBar = this.pipWindow.document.getElementById('volume');playPauseBtn.addEventListener('click', () => {if (this.video.paused) {this.video.play();} else {this.video.pause();}});stopBtn.addEventListener('click', () => {this.video.pause();this.video.currentTime = 0;});muteBtn.addEventListener('click', () => {this.video.muted = !this.video.muted;});progressBar.addEventListener('input', (e) => {const time = (e.target.value / 100) * this.video.duration;this.video.currentTime = time;});volumeBar.addEventListener('input', (e) => {this.video.volume = e.target.value;});// 更新进度显示this.video.addEventListener('timeupdate', () => {this.updateProgress();});}updateProgress() {if (!this.pipWindow) return;const progress = (this.video.currentTime / this.video.duration) * 100;const progressBar = this.pipWindow.document.getElementById('progress');const timeDisplay = this.pipWindow.document.getElementById('timeDisplay');if (progressBar) {progressBar.value = progress;}if (timeDisplay) {const current = this.formatTime(this.video.currentTime);const total = this.formatTime(this.video.duration);timeDisplay.textContent = `${current} / ${total}`;}}formatTime(seconds) {const mins = Math.floor(seconds / 60);const secs = Math.floor(seconds % 60);return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;}
}// 使用示例
// const video = document.getElementById('myVideo');
// const controller = new MediaControllerPiP(video);
// controller.openController();
2. 数据监控仪表板
class MonitoringDashboard {constructor() {this.pipWindow = null;this.updateInterval = null;}async open() {try {this.pipWindow = await documentPictureInPicture.requestWindow({width: 350,height: 250});this.setupDashboard();this.startMonitoring();// 窗口关闭时清理定时器this.pipWindow.addEventListener('beforeunload', () => {this.stopMonitoring();});} catch (error) {console.error('打开监控面板失败:', error);}}setupDashboard() {this.pipWindow.document.head.innerHTML = `<style>body {font-family: Arial, sans-serif;margin: 0;padding: 15px;background: #0f0f0f;color: #00ff00;font-size: 12px;}.metric {display: flex;justify-content: space-between;margin-bottom: 8px;padding: 5px;background: #1a1a1a;border-radius: 3px;}.value {font-weight: bold;}.status-good { color: #00ff00; }.status-warning { color: #ffaa00; }.status-error { color: #ff0000; }</style>`;this.pipWindow.document.body.innerHTML = `<div class="dashboard"><h3>系统监控</h3><div class="metric"><span>CPU 使用率:</span><span class="value" id="cpu">--</span></div><div class="metric"><span>内存使用:</span><span class="value" id="memory">--</span></div><div class="metric"><span>网络状态:</span><span class="value" id="network">--</span></div><div class="metric"><span>活跃连接:</span><span class="value" id="connections">--</span></div><div class="metric"><span>最后更新:</span><span class="value" id="lastUpdate">--</span></div></div>`;}startMonitoring() {this.updateMetrics();this.updateInterval = setInterval(() => {this.updateMetrics();}, 2000);}stopMonitoring() {if (this.updateInterval) {clearInterval(this.updateInterval);this.updateInterval = null;}}async updateMetrics() {if (!this.pipWindow) return;// 模拟获取系统指标const metrics = await this.getSystemMetrics();const cpuElement = this.pipWindow.document.getElementById('cpu');const memoryElement = this.pipWindow.document.getElementById('memory');const networkElement = this.pipWindow.document.getElementById('network');const connectionsElement = this.pipWindow.document.getElementById('connections');const lastUpdateElement = this.pipWindow.document.getElementById('lastUpdate');if (cpuElement) {cpuElement.textContent = `${metrics.cpu}%`;cpuElement.className = `value ${this.getStatusClass(metrics.cpu, 80, 90)}`;}if (memoryElement) {memoryElement.textContent = `${metrics.memory}%`;memoryElement.className = `value ${this.getStatusClass(metrics.memory, 75, 85)}`;}if (networkElement) {networkElement.textContent = metrics.networkStatus;networkElement.className = `value status-${metrics.networkStatus === '在线' ? 'good' : 'error'}`;}if (connectionsElement) {connectionsElement.textContent = metrics.connections;connectionsElement.className = 'value status-good';}if (lastUpdateElement) {lastUpdateElement.textContent = new Date().toLocaleTimeString();}}async getSystemMetrics() {// 模拟系统指标数据return {cpu: Math.floor(Math.random() * 100),memory: Math.floor(Math.random() * 100),networkStatus: Math.random() > 0.1 ? '在线' : '离线',connections: Math.floor(Math.random() * 50) + 10};}getStatusClass(value, warningThreshold, errorThreshold) {if (value >= errorThreshold) return 'status-error';if (value >= warningThreshold) return 'status-warning';return 'status-good';}
}// 使用示例
// const dashboard = new MonitoringDashboard();
// dashboard.open();
最佳实践
1. 样式处理
function copyStylesToPipWindow(pipWindow) {// 复制所有样式表Array.from(document.styleSheets).forEach(styleSheet => {try {const cssRules = Array.from(styleSheet.cssRules);const style = pipWindow.document.createElement('style');style.textContent = cssRules.map(rule => rule.cssText).join('\n');pipWindow.document.head.appendChild(style);} catch (e) {// 处理跨域样式表console.warn('无法复制样式表:', e);}});// 复制内联样式const linkElements = document.querySelectorAll('link[rel="stylesheet"]');linkElements.forEach(link => {const newLink = pipWindow.document.createElement('link');newLink.rel = 'stylesheet';newLink.href = link.href;pipWindow.document.head.appendChild(newLink);});
}
2. 响应式设计
function createResponsivePipWindow(content, minWidth = 300, minHeight = 200) {return documentPictureInPicture.requestWindow({width: Math.max(minWidth, window.innerWidth * 0.3),height: Math.max(minHeight, window.innerHeight * 0.4)}).then(pipWindow => {// 添加响应式样式const style = pipWindow.document.createElement('style');style.textContent = `body {margin: 0;padding: 10px;box-sizing: border-box;overflow: auto;}@media (max-width: 400px) {body { padding: 5px; font-size: 14px; }}`;pipWindow.document.head.appendChild(style);pipWindow.document.body.innerHTML = content;return pipWindow;});
}
3. 错误处理和降级方案
class PictureInPictureManager {static async openWithFallback(content, options = {}) {try {// 检查 API 支持if (!('documentPictureInPicture' in window)) {throw new Error('API_NOT_SUPPORTED');}// 检查是否已有窗口打开if (documentPictureInPicture.window) {documentPictureInPicture.window.close();}const pipWindow = await documentPictureInPicture.requestWindow(options);return pipWindow;} catch (error) {console.warn('画中画失败,使用降级方案:', error);return this.createModalFallback(content);}}static createModalFallback(content) {// 创建模态框作为降级方案const modal = document.createElement('div');modal.style.cssText = `position: fixed;top: 50%;left: 50%;transform: translate(-50%, -50%);background: white;border: 1px solid #ccc;box-shadow: 0 4px 6px rgba(0,0,0,0.1);z-index: 10000;max-width: 90vw;max-height: 90vh;overflow: auto;`;modal.innerHTML = content;document.body.appendChild(modal);// 返回类似 Window 的对象return {document: { body: modal },close: () => modal.remove(),addEventListener: (event, handler) => {if (event === 'beforeunload') {// 在模态框被移除时触发const observer = new MutationObserver((mutations) => {mutations.forEach((mutation) => {if (mutation.type === 'childList' && !document.body.contains(modal)) {handler();observer.disconnect();}});});observer.observe(document.body, { childList: true });}}};}
}
注意事项和限制
1. 安全限制
- 只能在用户手势(如点击)触发的情况下调用
- 同时只能有一个画中画窗口
- 不能直接访问父窗口的 DOM
2. 性能考虑
- 画中画窗口会消耗额外的系统资源
- 避免在窗口中放置过于复杂的内容
- 及时清理事件监听器和定时器
3. 用户体验
- 提供清晰的打开/关闭画中画的控制
- 确保画中画内容在小窗口中仍然可用
- 考虑用户可能调整窗口大小
调试技巧
1. 检查窗口状态
function debugPipWindow() {const pipWindow = documentPictureInPicture.window;if (pipWindow) {console.log('画中画窗口状态:');console.log('- 宽度:', pipWindow.innerWidth);console.log('- 高度:', pipWindow.innerHeight);console.log('- 是否关闭:', pipWindow.closed);console.log('- 文档标题:', pipWindow.document.title);} else {console.log('没有活跃的画中画窗口');}
}
2. 事件监听
// 监听所有画中画相关事件
documentPictureInPicture.addEventListener('enter', (event) => {console.log('画中画窗口打开:', event.window);event.window.addEventListener('resize', () => {console.log('窗口大小改变:', event.window.innerWidth, 'x', event.window.innerHeight);});event.window.addEventListener('beforeunload', () => {console.log('画中画窗口即将关闭');});
});
总结
documentPictureInPicture API 为 Web 应用提供了创建浮动窗口的强大能力。通过合理使用这个 API,可以显著提升用户体验,特别是在需要多任务处理或实时监控的场景中。
记住要:
- 始终检查浏览器支持
- 提供适当的降级方案
- 注意性能和用户体验
- 正确处理窗口生命周期
- 遵循 Web 标准和最佳实践
这个 API 还在不断发展中,未来可能会有更多功能和改进。保持关注最新的规范更新和浏览器支持情况。