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 还在不断发展中,未来可能会有更多功能和改进。保持关注最新的规范更新和浏览器支持情况。