Skip to content

Commit a232f1a

Browse files
committed
demo: add dual pip demo
1 parent 85c23e7 commit a232f1a

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>Cobalt Dual Picture-in-Picture Demo</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<style>
8+
body {
9+
background-color: #121212;
10+
color: #fff;
11+
font-family: sans-serif;
12+
margin: 0;
13+
overflow: hidden;
14+
width: 100vw;
15+
height: 100vh;
16+
}
17+
18+
.ui-layer {
19+
display: none;
20+
}
21+
22+
.video-container {
23+
position: absolute;
24+
top: 0;
25+
left: 0;
26+
width: 100%;
27+
height: 100%;
28+
display: flex;
29+
gap: 40px;
30+
align-items: center;
31+
justify-content: center;
32+
}
33+
34+
.video-wrapper {
35+
position: relative; /* Layout naturally via flexbox */
36+
width: 533px; /* Slightly larger nice 16:9 533x300 format */
37+
height: 300px;
38+
transition: all 0.5s ease;
39+
}
40+
41+
/* V1 is Texture mode so it natively supports Web overflow clipping beautifully */
42+
#v1-wrap {
43+
border-radius: 16px;
44+
overflow: hidden;
45+
}
46+
47+
/* V2 is Overlay mode so it must use stencil masking rather than overflow:hidden */
48+
#v2-wrap {
49+
border-radius: 16px;
50+
/* No overflow hidden here intentionally */
51+
}
52+
53+
.video-wrapper video {
54+
position: absolute;
55+
top: 0; left: 0;
56+
width: 100%;
57+
height: 100%;
58+
background: transparent;
59+
border: none;
60+
object-fit: cover;
61+
transition: all 0.5s ease;
62+
}
63+
64+
/* For V2 Hardware Overlay: Precise pixel overscan hiding 4.6px letterboxing safely */
65+
#video2 {
66+
top: -10px; left: -10px;
67+
width: calc(100% + 20px);
68+
height: calc(100% + 20px);
69+
}
70+
71+
/* The visual boundary stencil for V2 perfectly sealing the overscan */
72+
.overlay-mask {
73+
position: absolute;
74+
top: -24px; left: -24px;
75+
width: calc(100% + 48px);
76+
height: calc(100% + 48px);
77+
border: 24px solid #121212;
78+
border-radius: 40px; /* 40px outer minus 24px border = 16px exact inner hole radius */
79+
box-sizing: border-box;
80+
pointer-events: none;
81+
z-index: 2;
82+
transition: all 0.5s ease;
83+
}
84+
85+
.pip-corner {
86+
position: fixed;
87+
width: 427px;
88+
height: 240px;
89+
right: 50px;
90+
bottom: 50px;
91+
z-index: 10;
92+
transition: all 0.5s ease;
93+
}
94+
.fullscreen {
95+
border-radius: 0;
96+
position: fixed;
97+
top: 0; left: 0; bottom: 0; right: 0;
98+
width: 100vw; height: 100vh;
99+
margin: 0;
100+
z-index: 5;
101+
}
102+
.fullscreen .overlay-mask {
103+
opacity: 0;
104+
}
105+
.fullscreen #video2 {
106+
top: 0; left: 0; width: 100%; height: 100%;
107+
}
108+
</style>
109+
</head>
110+
<body>
111+
<!-- Keyboard UI Driven: No buttons -->
112+
113+
<!-- Container for videos -->
114+
<div class="video-container">
115+
<div id="v1-wrap" class="video-wrapper"><video id="video1" autoplay muted loop playsinline></video></div>
116+
<div id="v2-wrap" class="video-wrapper"><video id="video2" autoplay muted loop playsinline></video><div class="overlay-mask"></div></div>
117+
</div>
118+
119+
<script src="dual-pip-demo.js"></script>
120+
</body>
121+
</html>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2026 The Cobalt Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const v1 = document.getElementById('video1');
16+
const v2 = document.getElementById('video2');
17+
const v1Wrapper = document.getElementById('v1-wrap');
18+
const v2Wrapper = document.getElementById('v2-wrap');
19+
20+
21+
function log(msg) {
22+
console.log('JS: ' + msg);
23+
}
24+
25+
const url1 = 'vp9-720p.webm';
26+
const url2 = 'https://storage.googleapis.com/ytlr-cert.appspot.com/test-materials/media/big-buck-bunny-vp9-480p-30fps.webm';
27+
28+
function bootstrapVideo(videoObj, url, useTexture = false) {
29+
return new Promise((resolve) => {
30+
if (typeof MediaSource === 'undefined') { log('MSE not supported'); return resolve(); }
31+
const mediaSource = new MediaSource();
32+
mediaSource.addEventListener('sourceopen', async () => {
33+
try {
34+
const isMp4 = url.toLowerCase().includes('.mp4');
35+
const mimeType = isMp4 ? 'video/mp4; codecs="avc1.640028"' : 'video/webm; codecs="vp9"';
36+
const codecString = mimeType + (useTexture ? '; decode-to-texture=true' : '');
37+
const sourceBuffer = mediaSource.addSourceBuffer(codecString);
38+
// Safely fetch only the first 5MB of media to prevent JS Heap OOM
39+
// crashes when testing massive external videos on constrained TV memory
40+
const response = await fetch(url, { headers: { 'Range': 'bytes=0-5000000' } });
41+
if (!response.ok) {
42+
throw new Error(`Failed to fetch video: ${response.status} ${response.statusText}`);
43+
}
44+
const data = await response.arrayBuffer();
45+
sourceBuffer.addEventListener('updateend', () => {
46+
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
47+
mediaSource.endOfStream();
48+
videoObj.play().catch(e => log('Play prevented: ' + e));
49+
resolve();
50+
}
51+
}, { once: true });
52+
sourceBuffer.appendBuffer(data);
53+
} catch (error) { log('Error loading MSE: ' + error); resolve(); }
54+
}, { once: true });
55+
56+
videoObj.src = URL.createObjectURL(mediaSource);
57+
videoObj.loop = true;
58+
});
59+
}
60+
61+
let isStage3 = false;
62+
let isTransitioning = false;
63+
64+
document.addEventListener('keydown', async (e) => {
65+
// Bind the down button to enter and exit pip
66+
if (e.key !== 'ArrowDown') return;
67+
if (isTransitioning) return;
68+
isTransitioning = true;
69+
70+
try {
71+
if (!isStage3) {
72+
log('Stage 3: Native PiP for V1, Fullscreen for V2');
73+
74+
try {
75+
if (typeof v1.requestPictureInPicture !== 'function') {
76+
throw new Error('Picture-in-Picture API is not supported on this device');
77+
}
78+
await v1.requestPictureInPicture();
79+
80+
setTimeout(() => {
81+
v2Wrapper.style.transition = 'none';
82+
v2Wrapper.classList.add('fullscreen');
83+
84+
// Restore transition shortly after jump is completed
85+
setTimeout(() => {
86+
v2Wrapper.style.transition = '';
87+
}, 100);
88+
}, 300);
89+
90+
isStage3 = true;
91+
} catch (err) { log('Native PiP Error: ' + err.message); }
92+
93+
isStage3 = true;
94+
} else {
95+
log('Stage 2: Side-by-Side (Exiting PiP)');
96+
97+
v2Wrapper.classList.remove('fullscreen');
98+
try { await document.exitPictureInPicture(); } catch (err) {}
99+
100+
isStage3 = false;
101+
}
102+
} catch(e) { log(e.message); }
103+
104+
// Wait for the 500ms CSS transition to mathematically conclude before allowing another toggle
105+
setTimeout(() => { isTransitioning = false; }, 500);
106+
});
107+
108+
log('Bootstrapping decoders...');
109+
Promise.all([
110+
bootstrapVideo(v1, url1, true), // V1 Texture
111+
bootstrapVideo(v2, url2, false) // V2 Overlay
112+
]).then(() => {
113+
// Both decoders are now initialized.
114+
// Wait until V1 (the later one) actually fires its first frame.
115+
// When V1 starts rendering to its WebGL texture, Cobalt's compositor
116+
// does a deep structural redraw that historically wipes V2's overlay hole
117+
const fixV2Hole = () => {
118+
// Force a minor width change to recalculate Cobalt's compositor safely.
119+
// (Do NOT use `transform`, as it breaks `position: fixed` inside WebKit/Cobalt)
120+
v2.style.width = '105%';
121+
setTimeout(() => {
122+
v2.style.width = '';
123+
log('Ready!');
124+
}, 50);
125+
};
126+
127+
if (v1.readyState >= 3) {
128+
fixV2Hole();
129+
} else {
130+
v1.addEventListener('playing', fixV2Hole, { once: true });
131+
}
132+
});
363 KB
Binary file not shown.

0 commit comments

Comments
 (0)