зеркало из https://github.com/GoogleChrome/kino.git
Коммит
3d245d898c
|
@ -4,7 +4,8 @@
|
|||
"https://kinoweb-dev.web.app",
|
||||
"https://kinoweb-dev--staging-2ra3ji0i.web.app",
|
||||
"https://kinoweb.dev",
|
||||
"http://localhost:5000"
|
||||
"http://localhost:5000",
|
||||
"https://www.gstatic.com"
|
||||
],
|
||||
"responseHeader": [
|
||||
"Accept-Ranges",
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"headers": [
|
||||
{
|
||||
"key": "Content-Security-Policy",
|
||||
"value": "script-src 'self'; object-src 'none'; base-uri 'none'"
|
||||
"value": "script-src 'self' www.gstatic.com; object-src 'none'; base-uri 'none'"
|
||||
}
|
||||
]
|
||||
}],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kino",
|
||||
"version": "1.0.0-beta4",
|
||||
"version": "1.0.0-beta5",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@ -2343,9 +2343,9 @@
|
|||
}
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true
|
||||
},
|
||||
"call-bind": {
|
||||
|
@ -6441,6 +6441,15 @@
|
|||
"@rollup/pluginutils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"rollup-plugin-svg": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svg/-/rollup-plugin-svg-2.0.0.tgz",
|
||||
"integrity": "sha512-DmE7dSQHo1SC5L2uH2qul3Mjyd5oV6U1aVVkyvTLX/mUsRink7f1b1zaIm+32GEBA6EHu8H/JJi3DdWqM53ySQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"rollup-pluginutils": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"rollup-plugin-terser": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
|
||||
|
@ -6481,6 +6490,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"rollup-pluginutils": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-1.5.2.tgz",
|
||||
"integrity": "sha1-HhVud4+UtyVb+hs9AXi+j1xVJAg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"estree-walker": "^0.2.1",
|
||||
"minimatch": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"estree-walker": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.2.1.tgz",
|
||||
"integrity": "sha1-va/oCVOD2EFNXcLs9MkXO225QS4=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
|
@ -6609,9 +6636,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
|
||||
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz",
|
||||
"integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
|
@ -6844,14 +6871,14 @@
|
|||
}
|
||||
},
|
||||
"terser": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.7.2.tgz",
|
||||
"integrity": "sha512-0Omye+RD4X7X69O0eql3lC4Heh/5iLj3ggxR/B5ketZLOtLiOqukUgjw3q4PDnNQbsrkKr3UMypqStQG3XKRvw==",
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.9.0.tgz",
|
||||
"integrity": "sha512-h5hxa23sCdpzcye/7b8YqbE5OwKca/ni0RQz1uRX3tGh8haaGHqcuSqbGRybuAKNdntZ0mDgFNXPJ48xQ2RXKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"commander": "^2.20.0",
|
||||
"source-map": "~0.7.2",
|
||||
"source-map-support": "~0.5.19"
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kino",
|
||||
"version": "1.0.0-beta4",
|
||||
"version": "1.0.0-beta5",
|
||||
"description": "A sample offline streaming video PWA built for web.dev/media",
|
||||
"main": "src/index.js",
|
||||
"author": "Google",
|
||||
|
@ -36,6 +36,7 @@
|
|||
"rollup": "^2.50.4",
|
||||
"rollup-plugin-execute": "^1.1.1",
|
||||
"rollup-plugin-import-css": "^2.0.1",
|
||||
"rollup-plugin-svg": "^2.0.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"tmpl": ">=1.0.5"
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
--icon: #F7F7F8;
|
||||
--footer-text: #5a585b;
|
||||
--accent-background: #12292B;
|
||||
--accent-background-alpha: #12292BCC;
|
||||
--accent: #00C9DB;
|
||||
--accent-text: #fff;
|
||||
--separator: #3D3B3F;
|
||||
|
@ -56,6 +57,7 @@
|
|||
--icon: #858287;
|
||||
--footer-text: #5a585b;
|
||||
--accent-background: #ECEFFF;
|
||||
--accent-background-alpha: #ECEFFFCC;
|
||||
--accent: #3740FF;
|
||||
--accent-text: #000;
|
||||
--separator: #E6E4E7;
|
||||
|
@ -543,11 +545,15 @@ main {
|
|||
* Single Video
|
||||
*/
|
||||
article {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(var(--gutter) * 3);
|
||||
margin-bottom: calc(var(--gutter) * 3);
|
||||
border-bottom: 1px solid var(--separator);
|
||||
overflow: hidden;
|
||||
}
|
||||
article .video-content {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
@ -611,69 +617,82 @@ h3[id*="example"]{
|
|||
font-size: 24px;
|
||||
margin-bottom: calc(var(--gutter) / 2);
|
||||
}
|
||||
article pre,
|
||||
article pre code,
|
||||
.code-sample .code-sample--content {
|
||||
max-width: 760px;
|
||||
white-space: pre-wrap;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-family: monospace;
|
||||
padding: var(--gutter);
|
||||
border: 1px solid var(--code-border);
|
||||
background: var(--code-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.video-container {
|
||||
--play-button-size: 112px;
|
||||
--aspect-ratio-16-9-padding: 56.25%;
|
||||
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
.video-container--image {
|
||||
height: 0;
|
||||
padding-bottom: var(--aspect-ratio-16-9-padding);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
flex-flow: column nowrap;
|
||||
.video-container--overlay {
|
||||
display: none;
|
||||
}
|
||||
.video-container--image picture img,
|
||||
.video-container--image > img {
|
||||
.video-container.has-player {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.has-overlay .video-container--overlay {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
/*
|
||||
* Centers the button without using absolute positioning.
|
||||
*
|
||||
* This helps to avoid stacking issues and clashes with fixed navigation.
|
||||
*/
|
||||
margin-bottom: calc(-1 * (var(--aspect-ratio-16-9-padding) / 2) - (var(--play-button-size) / 2));
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center center;
|
||||
}
|
||||
.video-container .play {
|
||||
color: #000;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
width: var(--play-button-size);
|
||||
height: var(--play-button-size);
|
||||
padding: 0;
|
||||
.has-overlay .video-container--overlay > * {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.has-overlay .video-container--overlay button {
|
||||
color: var(--accent);
|
||||
background: var(--accent-background-alpha);
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.action--play {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, .5);
|
||||
}
|
||||
button.action--unmute {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.action--unmute path {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
.video-container .video-container--player {
|
||||
display: none;
|
||||
height: 720px;
|
||||
}
|
||||
.video-container.has-player .video-container--image {
|
||||
display: none;
|
||||
}
|
||||
.video-container.has-player .video-container--player {
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
.video--disabled .video-container--image,
|
||||
.video--disabled .video-container--overlay,
|
||||
.video--disabled video-downloader {
|
||||
filter: grayscale(1);
|
||||
pointer-events: none;
|
||||
|
|
|
@ -15,9 +15,10 @@
|
|||
*/
|
||||
|
||||
import css from 'rollup-plugin-import-css';
|
||||
import generateApi from './src/js/utils/generateApi.js';
|
||||
import generateCache from './src/js/utils/generateCache';
|
||||
import svg from 'rollup-plugin-svg';
|
||||
import json from '@rollup/plugin-json';
|
||||
import generateApi from './src/js/utils/generateApi';
|
||||
import generateCache from './src/js/utils/generateCache';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
|
||||
const isWatch = process.env.npm_lifecycle_event === 'watch';
|
||||
|
@ -33,6 +34,7 @@ export default [
|
|||
generateApi(),
|
||||
json(),
|
||||
css(),
|
||||
svg(),
|
||||
isWatch ? {} : terser(),
|
||||
],
|
||||
},
|
||||
|
@ -43,7 +45,9 @@ export default [
|
|||
format: 'cjs',
|
||||
},
|
||||
plugins: [
|
||||
generateApi(),
|
||||
generateCache(),
|
||||
json(),
|
||||
isWatch ? {} : terser(),
|
||||
],
|
||||
},
|
||||
|
|
|
@ -9,6 +9,25 @@ video-sources:
|
|||
- src: https://storage.googleapis.com/kino-assets/single-video/video.mp4
|
||||
type: video/mp4; codecs="avc1.640032,mp4a.40.2"
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/single-video/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/single-video/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -10,9 +10,29 @@ video-sources:
|
|||
type: video/mp4; codecs="av01.0.04M.08, mp4a.40.2"
|
||||
- src: https://storage.googleapis.com/kino-assets/multiple-sources/hevc.mp4
|
||||
type: video/mp4; codecs="hev1.1.6.L93.90,mp4a.40.2"
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/multiple-sources/vp9.webm
|
||||
type: video/webm
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/multiple-sources/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/multiple-sources/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -10,6 +10,7 @@ video-sources:
|
|||
type: video/mp4; codecs="av01.0.04M.08, mp4a.40.2"
|
||||
- src: https://storage.googleapis.com/kino-assets/using-webvtt/hevc.mp4
|
||||
type: video/mp4; codecs="hev1.1.6.L93.90,mp4a.40.2"
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/using-webvtt/vp9.webm
|
||||
type: video/webm
|
||||
video-subtitles:
|
||||
|
@ -21,9 +22,28 @@ video-subtitles:
|
|||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/cap-cz.vtt
|
||||
srclang: cz
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/cap-cs.vtt
|
||||
srclang: cs
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/using-webvtt/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/using-webvtt/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -8,8 +8,12 @@ length: '1:04'
|
|||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/streaming-basics/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/streaming-basics/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
url-rewrites:
|
||||
- online: https://storage.googleapis.com/kino-assets/streaming-basics/manifest.mpd
|
||||
offline: https://storage.googleapis.com/kino-assets/streaming-basics/manifest-offline.mpd
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
|
@ -19,9 +23,28 @@ video-subtitles:
|
|||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/cap-cz.vtt
|
||||
srclang: cz
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/cap-cs.vtt
|
||||
srclang: cs
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/streaming-basics/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/streaming-basics/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -8,8 +8,12 @@ length: '1:04'
|
|||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/efficient-formats/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/efficient-formats/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
url-rewrites:
|
||||
- online: https://storage.googleapis.com/kino-assets/efficient-formats/manifest.mpd
|
||||
offline: https://storage.googleapis.com/kino-assets/efficient-formats/manifest-offline.mpd
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
|
@ -19,9 +23,28 @@ video-subtitles:
|
|||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/cap-cz.vtt
|
||||
srclang: cz
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/cap-cs.vtt
|
||||
srclang: cs
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/efficient-formats/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/efficient-formats/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -8,8 +8,12 @@ length: '1:04'
|
|||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/adaptive-streaming/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/adaptive-streaming/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
url-rewrites:
|
||||
- online: https://storage.googleapis.com/kino-assets/adaptive-streaming/manifest.mpd
|
||||
offline: https://storage.googleapis.com/kino-assets/adaptive-streaming/manifest-offline.mpd
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
|
@ -19,9 +23,28 @@ video-subtitles:
|
|||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/cap-cz.vtt
|
||||
srclang: cz
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/cap-cs.vtt
|
||||
srclang: cs
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/adaptive-streaming/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/adaptive-streaming/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
|
@ -0,0 +1,225 @@
|
|||
---
|
||||
title: Autoplay
|
||||
description: |
|
||||
Learn how to start video playback automatically and employ autoplay strategies that don't lead to degraded user experience.
|
||||
date: Septembed 22nd, 2021
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/autoplay/manifest.mpd
|
||||
type: application/dash+xml
|
||||
- src: https://storage.googleapis.com/kino-assets/autoplay/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/autoplay/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/autoplay/artwork-512x512.png
|
||||
type: image/png
|
||||
autoplay: true
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Sometimes the video is not just a _part_ of your web page content. Sometimes it
|
||||
is _the_ content and your users expect it to start playing automatically
|
||||
as soon as possible.
|
||||
|
||||
There are two main ways of accomplishing this autoplay behavior:
|
||||
|
||||
1. Using the `<video autoplay>` attribute.
|
||||
2. Using JavaScript.
|
||||
|
||||
Let's take a look at the benefits and limitations of each.
|
||||
|
||||
## Using the `autoplay` attribute
|
||||
|
||||
The better known and simpler way of instructing browsers to automatically play
|
||||
a video is using the `autoplay` attribute.
|
||||
|
||||
```html
|
||||
<video controls autoplay>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
</video>
|
||||
```
|
||||
|
||||
Browsers might not honor the attribute for various reasons, likely
|
||||
because a given video contains sound. Automatic playback of videos with sound often leads
|
||||
to poor user experience and browsers use multiple signals to determine
|
||||
if playback with sound should be allowed.
|
||||
|
||||
To improve the chance of browsers starting playback, choose to
|
||||
play the video muted.
|
||||
|
||||
```html
|
||||
<!-- Browsers will usually allow autoplay when
|
||||
the `muted` attribute is also present. -->
|
||||
<video controls autoplay muted>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
</video>
|
||||
```
|
||||
|
||||
**Note:** Unless you provide your own UI for the video controls, make sure
|
||||
to also use the `controls` attribute to let the browser render the default video
|
||||
controls.
|
||||
|
||||
## Using JavaScript
|
||||
|
||||
In JavaScript the `<video>` element contains a `play()` method that can be used
|
||||
to attempt to manually start playback as soon as possible.
|
||||
|
||||
```html
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
</video>
|
||||
<script>
|
||||
const videoElement = document.querySelector('video');
|
||||
videoElement.play().then(() => { /* video is playing */});
|
||||
</script>
|
||||
```
|
||||
|
||||
As with the `autoplay` attribute, browsers may choose to not play the
|
||||
video in this situation, especially if it contains sound and is not muted.
|
||||
|
||||
One benefit of calling the `play()` method is that it returns a promise
|
||||
that resolves when playback starts and rejects when it won't start for any
|
||||
reason. This gives you a chance to respond.
|
||||
|
||||
A good strategy is to mute the video and re-attempt the playback. If
|
||||
that fails, render a custom play button or don't do anything and let the user use video controls rendered by the browser.
|
||||
|
||||
```html
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
</video>
|
||||
<script>
|
||||
const videoElement = document.querySelector('video');
|
||||
|
||||
videoElement.play()
|
||||
.catch(() => {
|
||||
// Mute video and retry playback.
|
||||
videoElement.muted = true;
|
||||
videoElement.play()
|
||||
.catch(() => {
|
||||
// Can't autoplay with sound or muted.
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Note: In older browsers the `play()` method may not
|
||||
* return a promise. If you need to support
|
||||
* pre-2019 browsers, you could do:
|
||||
*
|
||||
* Promise.resolve(videoElement.play())
|
||||
* .catch(() => {
|
||||
* // Mute and try playing again.
|
||||
* })
|
||||
*
|
||||
* ... to wrap the non-promise return values as promises.
|
||||
</script>
|
||||
```
|
||||
|
||||
### Unmute button
|
||||
|
||||
Refresh this page and you'll likely notice the video now plays muted and there
|
||||
is a "Tap to unmute" button rendered over it.
|
||||
|
||||
That's a custom element styled and positioned to render over the video. When
|
||||
the button is clicked, simple JavaScript runs and removes the `muted` attribute
|
||||
from the video and it continues playing with sound.
|
||||
|
||||
This strategy combines the best of both worlds. Users are not going to get
|
||||
startled by sudden loud playback while the action necessary to achieve playback
|
||||
with sound is handily available.
|
||||
|
||||
```html
|
||||
<section>
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
</video>
|
||||
<button>🔊</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Only basic layout, no fancy styling. */
|
||||
section {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
button {
|
||||
font-size: 80px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const videoElement = document.querySelector('video');
|
||||
const unmuteButton = document.querySelector('button');
|
||||
|
||||
videoElement.play()
|
||||
.catch(() => {
|
||||
// Mute video and retry playback.
|
||||
videoElement.muted = true;
|
||||
videoElement.play()
|
||||
.then(() => {
|
||||
// When the unmute button is clicked,
|
||||
// unmute video and remove the button.
|
||||
unmuteButton.addEventListener('click', () => {
|
||||
videoElement.muted = false;
|
||||
unmuteButton.remove();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Can't autoplay with sound or muted.
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Engagement signals
|
||||
|
||||
Browsers use various heuristics to determine whether automatic playback with
|
||||
sound is expected by the user or not. These heuristics differ between browsers,
|
||||
but here are a few example use cases where autoplay with sound is likely:
|
||||
|
||||
* When the newly loaded page is a result of internal navigation initiated by
|
||||
the user, e.g. click, tap etc.
|
||||
* When the user has installed the PWA or added the site to their home screen.
|
||||
* When user engagement signals are strong, e.g. on video sites where the user
|
||||
regularly watches the content.
|
||||
|
||||
Find more in-depth information at the [Chrome Developers website].
|
||||
|
||||
## What's Next?
|
||||
|
||||
In the [next article], we are going explore the Picture-in-Picture API, which can be used to play videos and other media sources in a floating window that stays on top of other applications.
|
||||
|
||||
[Chrome Developers website]: https://developer.chrome.com/blog/autoplay/
|
||||
[next article]: /picture-in-picture/
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
title: Picture-in-Picture
|
||||
description: |
|
||||
Play your videos in a detached, floating window that stays on top of other applications using the Picture-in-Picture API.
|
||||
date: January 31st, 2022
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/picture-in-picture/manifest.mpd
|
||||
type: application/dash+xml
|
||||
- src: https://storage.googleapis.com/kino-assets/picture-in-picture/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/picture-in-picture/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/picture-in-picture/artwork-512x512.png
|
||||
type: image/png
|
||||
pip: true
|
||||
---
|
||||
|
||||
## The basics
|
||||
|
||||
The three main pieces the Picture-in-Picture API introduces are:
|
||||
|
||||
1. The `requestPictureInPicture()` method of `<video>` elements.
|
||||
2. The `exitPictureInPicture()` method of the `document`.
|
||||
3. The `pictureInPictureElement` property of the `document` or a `shadowRoot`.
|
||||
|
||||
A minimal example of controlling the video's Picture-in-Picture state would only consist of calling `requestPictureInPicture` and `exitPictureInPicture` respectively:
|
||||
|
||||
```html
|
||||
<video src="video.mp4"></video>
|
||||
<button>Toggle Picture-in-Picture</button>
|
||||
|
||||
<script>
|
||||
const video = document.querySelector('video');
|
||||
const button = document.querySelector('button');
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
document.pictureInPictureElement === video
|
||||
? document.exitPictureInPicture()
|
||||
: video.requestPictureInPicture();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Checking for feature support
|
||||
|
||||
[Picture-in-Picture API support] is different from what you're probably used to. Even in browsers that implement it, the feature may be disabled by the `picture-in-picture` Feature Policy. There is a handy `pictureInPictureEnabled` property that will be present on the `document` in browsers that implement the API and its value will be `true` if Picture-in-Picture mode is allowed.
|
||||
|
||||
Be aware any request to enter or leave the Picture-in-Picture mode could fail or may be declined by the browser for other reasons, and so you should account for that:
|
||||
|
||||
```html
|
||||
<video src="video.mp4"></video>
|
||||
<!-- Disable the button by default. -->
|
||||
<button disabled>Picture-in-Picture not available</button>
|
||||
|
||||
<script>
|
||||
const video = document.querySelector('video');
|
||||
const button = document.querySelector('button');
|
||||
|
||||
const setupPip = () => {
|
||||
// Only enable the button when PiP is enabled.
|
||||
if (!document.pictureInPictureEnabled) return;
|
||||
|
||||
button.innerText = 'Toggle Picture-in-Picture';
|
||||
button.disabled = false;
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
// Entering and leaving PiP could take a little while, so we'll
|
||||
// disable the button while we wait.
|
||||
button.disabled = true;
|
||||
try {
|
||||
document.pictureInPictureElement === video
|
||||
? await document.exitPictureInPicture()
|
||||
: await video.requestPictureInPicture();
|
||||
} catch (e) {
|
||||
// Show or log the error.
|
||||
}
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
setupPip();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Advanced usage
|
||||
|
||||
There are several more things you might want to consider to make the Picture-in-Picture experience even better:
|
||||
|
||||
1. Use the [Media Session API] action handlers to display more controls in the Picture-in-Picture window.
|
||||
2. Listen to `enterpictureinpicture` and `leavepictureinpicture` events and update your UI when Picture-in-Picture window is shown or closed.
|
||||
3. Display a [MediaStream] object such as the user's webcam feed, another application window or canvas contents in the Picture-in-Picture window.
|
||||
|
||||
You can find more information and code snippets in a [Google Developers blog post].
|
||||
|
||||
## See it in action
|
||||
|
||||
When you play the video above, you can click the <span style="border-radius: 8px; background-color: var(--accent-background); width: 48px; height: 48px; display: inline-grid; place-items: center;" aria-label="Toggle picture in picture"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.25 15v6a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 21V8.25A1.5 1.5 0 0 1 3 6.75h3.75" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.75 3v7.5a1.5 1.5 0 0 0 1.5 1.5H21a1.5 1.5 0 0 0 1.5-1.5V3A1.5 1.5 0 0 0 21 1.5h-9.75A1.5 1.5 0 0 0 9.75 3Z" stroke="var(--accent)" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="square"></path><path d="M9 18.75V15H5.25M5.25 18.75 9 15" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg></span> button in the top right corner of the video to toggle Picture-in-Picture playback – if your browser supports the API.
|
||||
|
||||
## What's next?
|
||||
|
||||
Playing videos in a floating window is useful for a local viewing session. However, users often prefer to watch content on other devices such as their televisions. In the [next article] we'll talk about media casting – and Google Cast in particular.
|
||||
|
||||
[Picture-in-Picture API support]: https://developer.mozilla.org/en-US/docs/Web/API/Picture-in-Picture_API#htmlvideoelement.requestpictureinpicture
|
||||
[Media Session API]: https://web.dev/media-session/
|
||||
[MediaStream]: https://developer.mozilla.org/en-US/docs/Web/API/MediaStream
|
||||
[Google Developers blog post]: https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture
|
||||
[next article]: /google-cast/
|
|
@ -0,0 +1,230 @@
|
|||
---
|
||||
title: Google Cast
|
||||
description: |
|
||||
Use the Google Cast SDK and turn your application to a cast sender able to stream your media to a compatible network device.
|
||||
date: January 31st, 2022
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/google-cast/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/google-cast/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/google-cast/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/google-cast/artwork-512x512.png
|
||||
type: image/png
|
||||
cast: true
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
When one device controls media content playback on another device, we call it **casting**. These devices have to follow the same protocol in order to communicate. One of the most widely used casting protocols is Google Cast.
|
||||
|
||||
## Basic concepts
|
||||
|
||||
Before you can use Google Cast to stream your media to other devices, there are a few basic concepts you should understand:
|
||||
|
||||
* **Sender application**: A mobile or web application that uses Google Cast SDK to discover target devices on the network and to initialize and remotely control playback of media being cast to receiver.
|
||||
* **Receiver application**: A web application that runs on a Google Cast compatible device such as a Chromecast dongle or a Chromecast built-in TV or speaker and implements the media player UI and handles messages from the *Sender application*.
|
||||
* **Google Cast SDK**: Collection of API libraries and code samples to allow Android, iOS and web developers to implement sender and receiver applications.
|
||||
|
||||
**Note:** Developing a custom Receiver application is optional in many use cases. Two prebuilt options are available:
|
||||
|
||||
* [Default Media Web Receiver] – predefined UI and colors, but you don't need to [register] a custom receiver application.
|
||||
* [Styled Media Web Receiver] – ability to customize the UI and colors using CSS, but it is necessary to [register] a custom receiver application.
|
||||
|
||||
## Using the Cast Application Framework
|
||||
|
||||
The [Cast Application Framework] (CAF) is the part of Google Cast SDK which exposes objects, methods and events necessary for building web sender applications.
|
||||
|
||||
```html
|
||||
<script>
|
||||
// 1. When CAF initializes, the `__onGCastApiAvailable` is called.
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (isAvailable) initCastApi();
|
||||
};
|
||||
|
||||
function initCastApi() {
|
||||
// 2. `CastContext` holds all global context for the CAF.
|
||||
const context = cast.framework.CastContext.getInstance();
|
||||
|
||||
// 3. We use a default receiver app ID. If you want custom
|
||||
// styling or features, you must register your own app.
|
||||
//
|
||||
// @see https://developers.google.com/cast/docs/registration
|
||||
const receiverId = chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID;
|
||||
|
||||
context.setOptions({
|
||||
receiverApplicationId: receiverId,
|
||||
});
|
||||
|
||||
// 4. A `session` is a connection to a target device. Whenever its
|
||||
// state changes, we run the `castVideo` method.
|
||||
context.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
e => castVideo(e.sessionState),
|
||||
);
|
||||
|
||||
const castVideo = async (state) => {
|
||||
// 5. When the `session` state change event is `SESSION_STARTED`,
|
||||
// we try to load the video.
|
||||
if (state === 'SESSION_STARTED') {
|
||||
const session = context.getCurrentSession();
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
'http://example.com/video.mp4', // Provide a URL.
|
||||
'video/mp4; codecs="avc1.640032,mp4a.40.2"', // MIME + codecs.
|
||||
);
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
|
||||
try {
|
||||
// 6. Instruct the receiver application to load the media,
|
||||
// if it fails, be prepared to handle the error.
|
||||
await session.loadMedia(request);
|
||||
} catch (error) { /* Handle the error. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||
|
||||
<video src="video.mp4"></video>
|
||||
|
||||
<!-- Custom element defined by the framework. -->
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
Receiver applications may expose additional information about the media in their UI or provide additional features depending on metadata passed to them.
|
||||
|
||||
The [Cast Application Framework] defines several [Metadata classes] that developers can use to convey information like the content title, thumbnail, release dates, authors etc.
|
||||
|
||||
```js
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
'http://example.com/video.mp4', // Provide a URL.
|
||||
'video/mp4; codecs="avc1.640032,mp4a.40.2"', // MIME + codecs.
|
||||
);
|
||||
|
||||
// Image URLs need to be "wrapped" by the Image class.
|
||||
const thumbnail = new chrome.cast.Image('http://example.com/thumb.jpg');
|
||||
|
||||
// Our video is a short clip, but if it was a full-length movie,
|
||||
// we would use `MovieMediaMetadata` here.
|
||||
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
|
||||
mediaInfo.metadata.title = 'My Dog Chasing Geese';
|
||||
mediaInfo.metadata.images = [ thumbnail ];
|
||||
|
||||
// mediaInfo is later used to create a `LoadRequest`
|
||||
// const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
```
|
||||
|
||||
## Captions and subtitles
|
||||
|
||||
Media can contain additional [tracks of different types]. You can use text tracks to provide captions or subtitles for your videos.
|
||||
|
||||
```js
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
'http://example.com/video.mp4', // Provide a URL.
|
||||
'video/mp4; codecs="avc1.640032,mp4a.40.2"', // MIME + codecs.
|
||||
);
|
||||
|
||||
// Instantiate the Track object first.
|
||||
const captionsTrack = new chrome.cast.media.Track(
|
||||
'captions-en', // Unique track identifier.
|
||||
chrome.cast.media.TrackType.TEXT,
|
||||
);
|
||||
|
||||
captionsTrack.trackContentId = 'http://example.com/captions-en.vtt';
|
||||
captionsTrack.subtype = chrome.cast.media.TextTrackType.CAPTIONS;
|
||||
captionsTrack.name = 'English CC';
|
||||
captionsTrack.language = 'en-US'; // RFC 5646 Language Tag.
|
||||
captionsTrack.trackContentType = 'text/vtt';
|
||||
|
||||
// The `MediaInfo` object may contain several tracks of various types,
|
||||
// how and whether additional tracks are exposed in the UI depends
|
||||
// on the receiver application implementation.
|
||||
mediaInfo.tracks = [ captionsTrack ];
|
||||
|
||||
// mediaInfo is later used to create a `LoadRequest`
|
||||
// const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
```
|
||||
|
||||
## UX considerations
|
||||
|
||||
When the casting session starts, video playback pauses in the sender application and the `<google-cast-launcher>` changes its styling to indicate the session in progress.
|
||||
|
||||
However, in many cases it is useful to provide additional indication. One of the common patterns is to render a video overlay along with displaying a name of the device to which the media is being cast.
|
||||
|
||||
To achieve this, we can listen for `SESSION_STATE_CHANGED` events and add or remove an overlay element when session is started and ended:
|
||||
|
||||
```html
|
||||
<div class="cast-overlay" hidden>
|
||||
Casting to <span class="cast-target"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// The following code should be defined within
|
||||
// `function initCastApi() { ... }` defined previously.
|
||||
const context = cast.framework.CastContext.getInstance();
|
||||
|
||||
const overlay = document.querySelector('.cast-overlay');
|
||||
const target = document.querySelector('.cast-target');
|
||||
|
||||
// Bind the overlay visibility state to session state changes.
|
||||
context.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
e => {
|
||||
switch (e.sessionState) {
|
||||
case 'SESSION_ENDED':
|
||||
overlay.setAttribute('hidden', '');
|
||||
break;
|
||||
case 'SESSION_STARTED':
|
||||
case 'SESSION_RESUMED':
|
||||
const session = context.getCurrentSession();
|
||||
target.innerText = session.getCastDevice().friendlyName;
|
||||
overlay.removeAttribute('hidden');
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
## What's next?
|
||||
|
||||
In the [next article], we will shift gears and talk about media encryption and the Encrypted Media Extensions API.
|
||||
|
||||
[Default Media Web Receiver]: https://developers.google.com/cast/docs/web_receiver#default_media_web_receiver
|
||||
[Styled Media Web Receiver]: https://developers.google.com/cast/docs/web_receiver#styled_media_web_receiver
|
||||
[register]: https://developers.google.com/cast/docs/registration
|
||||
[Cast Application Framework]: https://developers.google.com/cast/docs/web_sender
|
||||
[Metadata classes]: https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media?hl=en
|
||||
[tracks of different types]: https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media#.TrackType
|
||||
[next article]: /encrypted-media-extensions/
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: User Experience
|
||||
slug: user-experience
|
||||
---
|
||||
|
||||
Unleash your videos and learn to implement autoplay, picture-in-picture and casting in ways that improve user experience.
|
|
@ -0,0 +1,258 @@
|
|||
---
|
||||
title: Encrypted Media Extensions
|
||||
description: |
|
||||
Protect your content by encrypting it, then use the Encrypted Media Extensions API to securely decrypt it within your application.
|
||||
date: January 31st, 2022
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/encrypted.mp4
|
||||
type: video/mp4; codecs="avc1.640032,mp4a.40.2"
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/artwork-512x512.png
|
||||
type: image/png
|
||||
encryption:
|
||||
type: org.w3.clearkey
|
||||
src: https://storage.googleapis.com/kino-assets/encrypted-media-extensions/encrypted.mp4
|
||||
mimeCodec: video/mp4; codecs="avc1.640032, mp4a.40.2"
|
||||
mediaKeySystemConfig:
|
||||
initDataTypes:
|
||||
- cenc
|
||||
videoCapabilities:
|
||||
- contentType: video/mp4; codecs="avc1.640032"
|
||||
audioCapabilities:
|
||||
- contentType: audio/mp4; codecs="mp4a.40.2"
|
||||
key:
|
||||
id: 279926496a7f5d25da69f2b3b216bfa6
|
||||
value: ccc0f2b3b279926496a7f5d25da692f6
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Web developers use tags like `<video>` and `<audio>` to embed media files on web pages. Browsers then offer users options of what to do with these embeds. One of those options is to download the media file, share it with others and play it back locally at any later time.
|
||||
|
||||
This default behavior is too permissive for many use cases, though. This is where the Encrypted Media Extensions API comes into play.
|
||||
|
||||
## Securing video playback
|
||||
|
||||
The simplest way to protect any data from being viewable is encryption. Even if you download an encrypted media file, you are going to need a key to actually play it.
|
||||
|
||||
**Try it:** [Download this video](https://storage.googleapis.com/kino-assets/encrypted-media-extensions/encrypted.mp4) and try playing it in any video player on your device. You'll notice you won't be able to. But when you run the same video in this application, it plays just fine.
|
||||
|
||||
The [Encrypted Media Extensions API] allows applications to decrypt media on the fly. It handles media keys and license exchange and can also directly render the media.
|
||||
|
||||
The main components the Encrypted Media Extensions API interacts with are:
|
||||
|
||||
* **Application:** Your video application, usually written in HTML5 and JS.
|
||||
* **Key System:** A decryption or protection system, e.g. Clear Key, Widevine etc.
|
||||
* **License Server:** Static data or a web server returning the decrpytion keys.
|
||||
* **Content Decryption Module (CDM):** Software or firmware decrypting the media.
|
||||
|
||||
**Note:** Some CDM systems have direct access to the device's hardware. Those systems don't return decrypted data back to the browser. Instead they decrypt, decode and output the media themselves.
|
||||
|
||||
Check out the diagram in the Encrypted Media Extensions [specification] for a detailed overview of the decryption flow.
|
||||
|
||||
## Encrypting video
|
||||
|
||||
To encrypt a video file, you need a few things:
|
||||
|
||||
* The **video** in an appropriate format.
|
||||
* One of more **cipher keys** to be used for encyrption.
|
||||
* Decision on which **encryption method** you want to use.
|
||||
|
||||
We already [discussed video formats] and determined that a safe common denominator for most simple use cases is using the Common Media Application Format (CMAF), i.e. fragmented MP4 files.
|
||||
|
||||
Moreover, to improve interoperability between different key systems, a standard called **Common Encryption (CENC)** emerged that defines allowed decryption and encryption techniques as well as a common format for key mapping.
|
||||
|
||||
In a real world commercial application, source video files would almost always be encoded, encrypted and packaged using tools like [Shaka Packager] into one or more streaming formats. However, for demonstration purposes, we'd want to keep things simple and just encrypt a single MP4 file.
|
||||
|
||||
First, you should take a look if your source MP4 file is fragmented. To find out, you can use the [mp4info] CLI tool from the [Bento4 toolkit].
|
||||
|
||||
```
|
||||
$ mp4info video.mp4
|
||||
|
||||
File:
|
||||
major brand: iso5
|
||||
minor version: 200
|
||||
compatible brand: iso6
|
||||
compatible brand: mp41
|
||||
fast start: yes
|
||||
|
||||
Movie:
|
||||
duration: 0 (media timescale units)
|
||||
duration: 0 (ms)
|
||||
time scale: 1000
|
||||
fragments: yes <---- THIS IS WHAT YOU'RE LOOKING FOR
|
||||
|
||||
Found 2 Tracks
|
||||
...
|
||||
...
|
||||
```
|
||||
|
||||
If your file is not already fragmented, you can use ffmpeg to fragment it:
|
||||
|
||||
```
|
||||
ffmpeg -i video.mp4
|
||||
-vcodec copy -acodec copy
|
||||
-movflags frag_keyframe+empty_moov+default_base_moof
|
||||
fragmented.mp4
|
||||
```
|
||||
|
||||
Then you should decide which key system you're going to use. In this tutorial we will be using a ClearKey protection just to illustrate the basic concepts of Encrypted Media Extensions usage.
|
||||
|
||||
**Note:** ClearKey protection does not hide the keys from users. In fact the keys are going to be easily obtainable from the license server or the application source code. To ensure your keys are not exposed to users, use a content protection system like [Widevine].
|
||||
|
||||
Because CENC requires us to use some variant of AES-128 encryption, both our key ID and the key itself are going to be 128 bits long. You can use a random sequence of 128 bits in this case, because the cipher is symmetric and we use the same key to encrypt and decrypt the data.
|
||||
|
||||
```
|
||||
$ openssl rand -hex 16
|
||||
c77fee35e51fd615a7b91afcb1091c5e <--- OUR KEY ID
|
||||
|
||||
$ openssl rand -hex 16
|
||||
eecdb2b549f02a7c97ce50c17f494ca0 <--- OUR KEY DATA
|
||||
```
|
||||
|
||||
Then we're going to encrypt the video using the [mp4encrypt] tool from the Bento4 toolkit:
|
||||
|
||||
```
|
||||
mp4encrypt
|
||||
--method MPEG-CENC
|
||||
--key 1:eecdb2b549f02a7c97ce50c17f494ca0:random
|
||||
--property 1:KID:c77fee35e51fd615a7b91afcb1091c5e
|
||||
--key 2:9abb7ab6cc4ad3b86c2193dadb1e786c:random
|
||||
--property 2:KID:045f7ecc35848ed7b3c012ea7614422f
|
||||
--global-option mpeg-cenc.eme-pssh:true
|
||||
fragmented.mp4 encrypted.mp4
|
||||
```
|
||||
|
||||
**Note:** The example above uses two different keys. One encrypts the video track (track #1) and the other encrypts audio (track #2).
|
||||
|
||||
Now your MP4 file is encrypted and ready to be played back in the browser using the [Encrypted Media Extensions API].
|
||||
|
||||
## Encrypted Media Extensions API usage
|
||||
|
||||
When web browsers download the `encrypted.mp4` file, they will determine the file is encrypted. The [PSSH box] included in the MP4 file also contains list of IDs of keys necessary to decrypt the video.
|
||||
|
||||
This information is sent to the ClearKey CDM, which in turn requests a license (i.e. the actual keys) from our application. In our case, the decryption keys are stored as plain text in the application.
|
||||
|
||||
This allows us to directly satisfy the license request without the use of any license server.
|
||||
|
||||
```js
|
||||
/**
|
||||
* KID:key pairs as non-padded base64 values.
|
||||
*/
|
||||
const KEYS = {
|
||||
x3_uNeUf1hWnuRr8sQkcXg: '7s2ytUnwKnyXzlDBf0lMoA',
|
||||
'BF9-zDWEjtezwBLqdhRCLw': 'mrt6tsxK07hsIZPa2x54bA',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a license for the given key IDs ("kids").
|
||||
*
|
||||
* @link https://www.w3.org/TR/encrypted-media/#clear-key-request-format
|
||||
* @link https://www.w3.org/TR/encrypted-media/#clear-key-license-format
|
||||
*
|
||||
* @param {Uint8Array} message Key session message.
|
||||
* @returns {Uint8Array} License data.
|
||||
*/
|
||||
const generateLicense = (message) => {
|
||||
const request = JSON.parse(new TextDecoder().decode(message));
|
||||
const keys = [];
|
||||
|
||||
request.kids.forEach((kid) => {
|
||||
keys.push({
|
||||
kty: 'oct',
|
||||
alg: 'A128KW',
|
||||
kid,
|
||||
k: KEYS[kid],
|
||||
});
|
||||
});
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify({
|
||||
keys,
|
||||
}));
|
||||
};
|
||||
|
||||
const video = document.querySelector('video');
|
||||
|
||||
/**
|
||||
* When browser encounters a PSSH box in the MP4 file,
|
||||
* it will emit an `encrypted` event and include any initData
|
||||
* that will be used by CDM to generate the license request.
|
||||
*/
|
||||
video.addEventListener(
|
||||
'encrypted',
|
||||
async ({ initDataType, initData }) => {
|
||||
const mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(
|
||||
'org.w3.clearkey',
|
||||
[
|
||||
{
|
||||
initDataTypes: ['cenc'],
|
||||
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.640032"' }],
|
||||
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
|
||||
}
|
||||
],
|
||||
);
|
||||
|
||||
const mediaKeys = await mediaKeySystemAccess.createMediaKeys();
|
||||
await video.setMediaKeys(mediaKeys);
|
||||
const keySession = mediaKeys.createSession();
|
||||
|
||||
keySession.addEventListener('message', async (e) => {
|
||||
const license = generateLicense(e.message);
|
||||
await keySession.update(license).catch( /* Handle error. */ );
|
||||
}, false);
|
||||
|
||||
/**
|
||||
* This will prompt the CDM to generate a license request.
|
||||
*/
|
||||
await keySession.generateRequest(initDataType, initData);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Note:** The `<video>` source should not be a static file. Instead web browsers usually require that the [Media Source Extensions API] is used to read the encrypted video data.
|
||||
|
||||
## What's Next?
|
||||
|
||||
[In the next article] we're going to open the topic of playback performance and talk about the Video Playback Quality API and the Media Capabilities API.
|
||||
|
||||
[Encrypted Media Extensions API]: https://developer.mozilla.org/en-US/docs/Web/API/Encrypted_Media_Extensions_API
|
||||
[specification]: https://www.w3.org/TR/encrypted-media/#introduction
|
||||
[Shaka Packager]: https://github.com/google/shaka-packager
|
||||
[discussed video formats]: /streaming-basics/#media-chunks-format
|
||||
[mp4info]: https://www.bento4.com/documentation/mp4info/
|
||||
[Bento4 toolkit]: https://www.bento4.com/documentation/
|
||||
[Widevine]: https://www.widevine.com/
|
||||
[mp4encrypt]: https://www.bento4.com/documentation/mp4encrypt/
|
||||
[PSSH box]: https://www.w3.org/TR/eme-stream-mp4/#init-data
|
||||
[license format]: https://www.w3.org/TR/encrypted-media/#clear-key-license-format
|
||||
[Media Source Extensions API]: /streaming-basics/#media-source-extensions
|
||||
[In the next article]: /playback-performance/
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
title: Playback Performance
|
||||
description: |
|
||||
Use the Media Capabilities and the Video Playback Quality APIs to estimate and measure playback efficiency and smoothness.
|
||||
date: February 18, 2022
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/playback-performance/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/playback-performance/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/playback-performance/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/playback-performance/artwork-512x512.png
|
||||
type: image/png
|
||||
stats: true
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Not all devices are able to play all video sources smoothly at all times.
|
||||
|
||||
* Older hardware may not be able to decode high resolution video quickly enough.
|
||||
* Mobile device performance may be different in battery saving modes.
|
||||
* Not all devices can use hardware acceleration to decode all video codecs.
|
||||
|
||||
Assuming you have encoded your video in multiple resolutions and codecs, you should now decide which source you'll serve by default in each device context.
|
||||
|
||||
To help you choose, there are two browser APIs able to give insights into video playback performance:
|
||||
|
||||
* [Media Capabilities API]: To obtain device's media decoding capabilities.
|
||||
* [Video Playback Quality API]: To get playback quality metrics for a playing video.
|
||||
|
||||
**Try it:** Play the video on this page. Notice the overlay that displays live values returned by the two APIs.
|
||||
|
||||
## Media Capabilities API
|
||||
|
||||
The [decodingInfo] method of the [Media Capabilities API] accepts a media configuration object and returns information about whether such media playback is going to be `supported`, `smooth` and `powerEfficient`.
|
||||
|
||||
```js
|
||||
const isSupported = (
|
||||
'mediaCapabilities' in navigator
|
||||
&& 'decodingInfo' in navigator.mediaCapabilities
|
||||
);
|
||||
|
||||
// Note: Use `isSupported` to bypass the logic below
|
||||
// if the Media Capabilities API is not supported.
|
||||
|
||||
const mediaConfiguration = {
|
||||
"type": "media-source", // Use "file" for a plain file playback.
|
||||
"video": {
|
||||
"contentType": "video/webm; codecs=\"vp09.00.40.08\"",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"bitrate": 3000000,
|
||||
"framerate": 23.976023976023978
|
||||
},
|
||||
"audio": {
|
||||
"contentType": "audio/mp4; codecs=\"mp4a.40.2\"",
|
||||
"bitrate": 128000,
|
||||
"samplerate": 44100,
|
||||
"channels": 2
|
||||
}
|
||||
}
|
||||
|
||||
navigator.mediaCapabilities.decodingInfo(mediaConfiguration).then(
|
||||
result => {
|
||||
console.log(`Supported: ${result.supported}`);
|
||||
console.log(`Smooth: ${result.smooth}`);
|
||||
console.log(`Power efficient: ${result.powerEfficient}`);
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Note:** There is also an [encodingInfo] method available in some browsers that will return the same type of information for media encoding capabilities of the current device.
|
||||
|
||||
## Video Playback Quality API
|
||||
|
||||
There is a [VideoPlaybackQuality object] associated with every `<video>` element. The `VideoPlaybackQuality` object contains a couple of key, live video metrics you can use to determine whether the current video playback is smooth.
|
||||
|
||||
```js
|
||||
const video = document.querySelector('video');
|
||||
const vq = video.getVideoPlaybackQuality();
|
||||
|
||||
// Print the number of total created frames
|
||||
// and dropped frames every second.
|
||||
setInterval(() => {
|
||||
console.log(`Total frames: ${vq.totalVideoFrames}`);
|
||||
console.log(`Dropped frames: ${vq.droppedVideoFrames}`);
|
||||
}, 1000);
|
||||
```
|
||||
|
||||
Data returned by the `VideoPlaybackQuality` object allows you to switch video source to a less demanding one in cases when the target device struggles to decode the data stream in time.
|
||||
|
||||
## What's Next?
|
||||
|
||||
Next we will [explore the Background Fetch API]. You'll learn how to initiate and handle media downloads that run in the background without depending on your web application being loaded by the browser.
|
||||
|
||||
[Media Capabilities API]: https://developer.mozilla.org/en-US/docs/Web/API/MediaCapabilities
|
||||
[Video Playback Quality API]: https://developer.mozilla.org/en-US/docs/Web/API/VideoPlaybackQuality
|
||||
[decodingInfo]: https://developer.mozilla.org/en-US/docs/Web/API/MediaCapabilities/decodingInfo
|
||||
[encodingInfo]: https://developer.mozilla.org/en-US/docs/Web/API/MediaCapabilities/encodingInfo
|
||||
[VideoPlaybackQuality object]: https://developer.mozilla.org/en-US/docs/Web/API/VideoPlaybackQuality
|
||||
[explore the Background Fetch API]: /background-fetch-api/
|
|
@ -0,0 +1,165 @@
|
|||
---
|
||||
title: Background Fetch
|
||||
description: |
|
||||
Use the Background Fetch API to download large files in the background while exposing the download progress to the user.
|
||||
date: February 22, 2022
|
||||
length: '1:04'
|
||||
video-sources:
|
||||
- src: https://storage.googleapis.com/kino-assets/background-fetch-api/manifest.mpd
|
||||
type: application/dash+xml
|
||||
cast: true
|
||||
- src: https://storage.googleapis.com/kino-assets/background-fetch-api/master.m3u8
|
||||
type: application/x-mpegURL
|
||||
video-subtitles:
|
||||
- default: true
|
||||
kind: captions
|
||||
label: English
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/cap-en.vtt
|
||||
srclang: en
|
||||
- default: false
|
||||
kind: captions
|
||||
label: Česky
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/cap-cs.vtt
|
||||
srclang: cz
|
||||
thumbnail: https://storage.googleapis.com/kino-assets/background-fetch-api/thumbnail.png
|
||||
media-session-artwork:
|
||||
- sizes: 96x96
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-96x96.png
|
||||
type: image/png
|
||||
- sizes: 128x128
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-128x128.png
|
||||
type: image/png
|
||||
- sizes: 192x192
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-192x192.png
|
||||
type: image/png
|
||||
- sizes: 256x256
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-256x256.png
|
||||
type: image/png
|
||||
- sizes: 384x384
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-384x384.png
|
||||
type: image/png
|
||||
- sizes: 512x512
|
||||
src: https://storage.googleapis.com/kino-assets/background-fetch-api/artwork-512x512.png
|
||||
type: image/png
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Web applications use the [Fetch API] to fetch resources, including file downloads, from the network. However if the user closes the application web page or navigates away, all in progress `fetch` requests are interrupted and the download halts.
|
||||
|
||||
The [Background Fetch API] allows web applications to offload a download operation to the browser. The browser exposes some UI to indicate that a download is in progress. When all files are downloaded, it exposes the downloaded data to your service worker.
|
||||
|
||||
[Background Fetch API] allows you to batch files into a single download operation. This is especially useful for downloading media, particularly for downloading streaming formats like MPEG DASH that often serve a single media item split into tens or even hundreds of chunks, each in its own file.
|
||||
|
||||
## Initiate the download
|
||||
|
||||
Your application needs to have a registered service worker in order to use the [Background Fetch API]. You will also need to make sure the API is supported in the user's browser before you use it.
|
||||
|
||||
```js
|
||||
if (!('BackgroundFetchManager' in self)) {
|
||||
// Use the Fetch API instead to download the files.
|
||||
}
|
||||
```
|
||||
|
||||
Assuming the current browser supports the Background Fetch API, you should first obtain a `ServiceWorkerRegistration` instance and then use its `backgroundFetch` property to initialize the background fetch operation.
|
||||
|
||||
```js
|
||||
navigator.serviceWorker.ready.then(async (swReg) => {
|
||||
const bgFetch = await swReg.backgroundFetch.fetch(
|
||||
// Provide your own meaningful ID.
|
||||
'my-video-id',
|
||||
|
||||
// Array of file URLs to be downloaded.
|
||||
[
|
||||
'/static/video-chunk-1.mp4',
|
||||
'/static/video-chunk-2.mp4',
|
||||
'/static/video-chunk-3.mp4',
|
||||
'/static/video-chunk-4.mp4',
|
||||
'/static/video-thumbnail.jpg',
|
||||
],
|
||||
|
||||
// Browsers may use these options
|
||||
// to provide users with more information
|
||||
// about the object being downloaded.
|
||||
//
|
||||
// Note: All options are optional.
|
||||
{
|
||||
title: 'My Video: Chunk Funk',
|
||||
icons: [{
|
||||
sizes: '300x300',
|
||||
src: '/video-icon-300x300.png',
|
||||
type: 'image/png',
|
||||
}],
|
||||
|
||||
// Total size of the downloaded
|
||||
// resources in bytes.
|
||||
downloadTotal: 60 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Access downloaded data
|
||||
|
||||
Once the background fetch is initialized, the browser will expose some kind of progress indication to the user. The download will now continue even if the user closes the application or navigates away.
|
||||
|
||||
After all files finish downloading, the browser is going to trigger a `backgroundfetchsuccess` event in your service worker context. This gives you an opportunity to access the downloaded data.
|
||||
|
||||
```js
|
||||
// In the service worker ↓
|
||||
|
||||
addEventListener('backgroundfetchsuccess', (event) => {
|
||||
// The `BackgroundFetchRegistration` instance.
|
||||
const bgFetch = event.registration;
|
||||
|
||||
event.waitUntil(async function() {
|
||||
// Returns an array of `BackgroundFetchRecord` objects.
|
||||
const records = await bgFetch.matchAll();
|
||||
|
||||
const promises = records.map(async (record) => {
|
||||
const response = await record.responseReady;
|
||||
|
||||
// TODO: Store the response object using the Cache or IndexedDB API
|
||||
});
|
||||
|
||||
// Wait until all responses are ready and saved locally
|
||||
await Promise.all(promises);
|
||||
|
||||
// Use the `updateUI` method to change
|
||||
// the initially set `title` or `icons`.
|
||||
event.updateUI({
|
||||
title: 'My Video stored for offline playback'
|
||||
});
|
||||
}());
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** In case one or more of the downloaded files can't be fetched or when the user actively aborts the download operation, a `backgroundfetchfailure` or `backgroundfetchabort` respectively will be triggered instead.
|
||||
|
||||
## Tracking progress
|
||||
|
||||
You can track the download progress via a `progress` event. Note that `downloadTotal` is going to be `0` if you didn't provide a value when initializing the background fetch operation.
|
||||
|
||||
```js
|
||||
// In the service worker ↓
|
||||
|
||||
bgFetch.addEventListener('progress', () => {
|
||||
// If we didn't provide a total, we can't provide a %.
|
||||
if (!bgFetch.downloadTotal) return;
|
||||
|
||||
const ratio = bgFetch.downloaded / bgFetch.downloadTotal;
|
||||
const percent = Math.round(ratio * 100);
|
||||
|
||||
console.log(`Download progress: ${percent}%`);
|
||||
});
|
||||
```
|
||||
|
||||
## Try it!
|
||||
|
||||
Go to [Kino Settings] and enable the _Download videos in the background_ feature. Then try to download any of the videos using the _Make available offline_ button under any of the videos on this site. If your [browser supports the Background Fetch API], you'll notice it downloads the video using its own UI.
|
||||
|
||||
[Fetch API]: https://developers.google.com/web/updates/2015/03/introduction-to-fetch
|
||||
[Background Fetch API]: https://wicg.github.io/background-fetch/
|
||||
[service worker]: https://developers.google.com/web/fundamentals/primers/service-workers
|
||||
[Kino Settings]: /settings/
|
||||
[browser supports the Background Fetch API]: https://caniuse.com/mdn-api_serviceworkerregistration_backgroundfetch
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: Enhancements
|
||||
slug: enhancements
|
||||
---
|
||||
|
||||
Use several more advanced media APIs implemented in the browsers today to make your video applications even more robust.
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
|
||||
<path d="M64 32C64 14.339 49.661 0 32 0S0 14.339 0 32s14.339 32 32 32 32-14.339 32-32Z" fill="#fff"/>
|
||||
<path d="M40 32.01c0 .505-.159 1.013-.477 1.419a7.73 7.73 0 0 1-.557.622l-.119.117c-1.671 1.771-5.827 4.434-7.935 5.288 0 .019-1.253.527-1.849.544h-.08c-.915 0-1.77-.503-2.207-1.32-.239-.449-.458-1.752-.478-1.769-.179-1.169-.298-2.957-.298-4.921 0-2.058.119-3.927.338-5.074 0-.019.219-1.069.358-1.419a2.55 2.55 0 0 1 1.114-1.205A2.834 2.834 0 0 1 29.063 24c.457.021 1.312.311 1.65.447 2.227.856 6.483 3.655 8.114 5.366.278.272.576.604.656.68.338.428.517.953.517 1.517Z" fill="#141216" fill-rule="nonzero"/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 811 B |
|
@ -0,0 +1 @@
|
|||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.997 13v6M27.997 11v10M6 5l20 22M14.019 7.874 19 4v9.354M19 19.3V28l-9-7H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h7.454" stroke="#3740FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
После Ширина: | Высота: | Размер: 289 B |
64
src/index.js
64
src/index.js
|
@ -46,7 +46,7 @@ import ErrorPage from './js/pages/Error';
|
|||
* Settings
|
||||
*/
|
||||
import { loadSetting } from './js/utils/settings';
|
||||
import { SETTING_KEY_TOGGLE_OFFLINE, SETTING_KEY_DARK_MODE } from './js/constants';
|
||||
import { SETTING_KEY_TOGGLE_OFFLINE, SETTING_KEY_DARK_MODE, CAST_BUTTON_HIDDEN_CLASSNAME } from './js/constants';
|
||||
|
||||
/**
|
||||
* Custom Elements definition.
|
||||
|
@ -152,3 +152,65 @@ if ('serviceWorker' in navigator) {
|
|||
navigator.serviceWorker.register('/sw.js');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a global closure that ensures that Google Cast
|
||||
* is only initialized once and allows the cast button
|
||||
* to be reused across pages.
|
||||
*/
|
||||
window.kinoInitGoogleCast = (function kinoInitGoogleCastIIFE() {
|
||||
let castButtonPromise = false;
|
||||
|
||||
/**
|
||||
* Initializes Google Cast Web Sender and returns a promise resolving
|
||||
* with a cast button.
|
||||
*
|
||||
* @returns {Promise<HTMLElement>} Custom <google-cast-launcher> element.
|
||||
*/
|
||||
return () => {
|
||||
if (!castButtonPromise) {
|
||||
castButtonPromise = new Promise((resolve) => {
|
||||
const castButton = document.createElement('button');
|
||||
const castCustomElement = document.createElement('google-cast-launcher');
|
||||
|
||||
castButton.setAttribute('aria-label', 'Cast this video');
|
||||
castButton.appendChild(castCustomElement);
|
||||
|
||||
const applyCastState = () => {
|
||||
const castState = window.cast.framework.CastContext.getInstance().getCastState();
|
||||
|
||||
castButton.classList.toggle(
|
||||
CAST_BUTTON_HIDDEN_CLASSNAME,
|
||||
castState === 'NO_DEVICES_AVAILABLE',
|
||||
);
|
||||
};
|
||||
|
||||
const initCastApi = () => {
|
||||
window.cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
});
|
||||
|
||||
window.cast.framework.CastContext.getInstance().addEventListener(
|
||||
window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
applyCastState,
|
||||
);
|
||||
applyCastState();
|
||||
|
||||
resolve(castButton);
|
||||
};
|
||||
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (isAvailable) {
|
||||
initCastApi();
|
||||
}
|
||||
};
|
||||
|
||||
const scriptEl = document.createElement('script');
|
||||
scriptEl.type = 'text/javascript';
|
||||
scriptEl.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
document.head.appendChild(scriptEl);
|
||||
});
|
||||
}
|
||||
return castButtonPromise;
|
||||
};
|
||||
}());
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
import getURLsForDownload from '../utils/getURLsForDownload';
|
||||
|
||||
const BG_FETCH_ID_TEMPLATE = /kino-(?<videoId>[a-z-_]+)/;
|
||||
|
||||
export default class BackgroundFetch {
|
||||
/**
|
||||
* Construct a new Background Fetch wrapper.
|
||||
*/
|
||||
constructor() {
|
||||
this.id = '';
|
||||
this.videoId = '';
|
||||
this.onprogress = () => {};
|
||||
this.ondone = () => {};
|
||||
}
|
||||
|
||||
async maybeAbort(swReg) {
|
||||
const existingBgFetch = await swReg.backgroundFetch.get(this.id);
|
||||
if (existingBgFetch) await existingBgFetch.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {BackgroundFetchRegistration} registration Background Fetch Registration object.
|
||||
*/
|
||||
fromRegistration(registration) {
|
||||
const matches = registration.id.match(BG_FETCH_ID_TEMPLATE);
|
||||
|
||||
this.id = registration.id;
|
||||
this.videoId = matches.groups?.videoId || '';
|
||||
}
|
||||
|
||||
async start(videoData) {
|
||||
this.videoId = videoData.id;
|
||||
this.id = `kino-${this.videoId}`;
|
||||
|
||||
const urls = await getURLsForDownload(this.videoId);
|
||||
|
||||
/** @type {Promise<Response[]>} */
|
||||
const requests = urls.map((url) => fetch(url, { method: 'HEAD' }));
|
||||
|
||||
Promise.all(requests).then((responses) => {
|
||||
const sizes = responses.map((response) => response.headers.get('Content-Length'));
|
||||
const downloadTotal = sizes.includes(null)
|
||||
? null
|
||||
: sizes.reduce((total, size) => total + parseInt(size, 10), 0);
|
||||
|
||||
// eslint-disable-next-line compat/compat
|
||||
navigator.serviceWorker.ready.then(async (swReg) => {
|
||||
await this.maybeAbort(swReg);
|
||||
|
||||
/** @type {BackgroundFetchRegistration} */
|
||||
const bgFetch = await swReg.backgroundFetch.fetch(
|
||||
this.id,
|
||||
urls,
|
||||
{
|
||||
title: `Downloading "${videoData.title}" video`,
|
||||
icons: videoData['media-session-artwork'] || {},
|
||||
downloadTotal,
|
||||
},
|
||||
);
|
||||
|
||||
bgFetch.addEventListener('progress', () => {
|
||||
const progress = bgFetch.downloadTotal
|
||||
? bgFetch.downloaded / bgFetch.downloadTotal
|
||||
: 0;
|
||||
|
||||
this.onprogress(progress);
|
||||
});
|
||||
|
||||
const messageChannel = new MessageChannel();
|
||||
|
||||
messageChannel.port1.onmessage = (e) => {
|
||||
if (e.data === 'done') {
|
||||
this.ondone();
|
||||
}
|
||||
};
|
||||
|
||||
const swController = navigator.serviceWorker.controller;
|
||||
|
||||
/**
|
||||
* Need to guard the `postMessage` logic below, because
|
||||
* `navigator.serviceWorker.controller` will be `null`
|
||||
* when a force-refresh request is made.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/controller
|
||||
*/
|
||||
if (swController) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'channel-port',
|
||||
}, [messageChannel.port2]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -28,6 +28,8 @@ import FixedBuffer from './FixedBuffer';
|
|||
* Utils.
|
||||
*/
|
||||
import getMimeByURL from '../utils/getMimeByURL';
|
||||
import getFileMetaForDownload from '../utils/getFileMetaForDownload';
|
||||
import rewriteURL from '../utils/rewriteURL';
|
||||
|
||||
/**
|
||||
* The DownloadManager is responsible for downloading videos from the network.
|
||||
|
@ -45,23 +47,46 @@ export default class DownloadManager {
|
|||
/**
|
||||
* Instantiates the download manager.
|
||||
*
|
||||
* @param {VideoDownloader} videoDownloader The associated video downloader object.
|
||||
* @param {string} videoId Video ID of the media to be downloaded.
|
||||
*/
|
||||
constructor(videoDownloader) {
|
||||
this.files = videoDownloader.internal.files || [];
|
||||
constructor(videoId) {
|
||||
this.videoId = videoId;
|
||||
this.paused = false;
|
||||
this.cancelled = false;
|
||||
|
||||
this.internal = {
|
||||
videoDownloader,
|
||||
};
|
||||
/** @type {Response[]} */
|
||||
this.responses = [];
|
||||
|
||||
this.onflush = () => {};
|
||||
/** @type {DownloadFlushHandler[]} */
|
||||
this.flushHandlers = [];
|
||||
|
||||
this.onfilemeta = () => {};
|
||||
|
||||
this.maybePrepareNextFile();
|
||||
this.bufferSetup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the downloaded data to any handlers.
|
||||
*
|
||||
* @param {FileMeta} fileMeta File meta.
|
||||
* @param {FileChunk} fileChunk File chunk.
|
||||
* @param {boolean} isDone Is this the last file chunk.
|
||||
*/
|
||||
flush(fileMeta, fileChunk, isDone) {
|
||||
this.flushHandlers.forEach((handler) => {
|
||||
handler(fileMeta, fileChunk, isDone);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a handler to receive downloaded data.
|
||||
*
|
||||
* @param {DownloadFlushHandler} flushHandler Flush handler.
|
||||
*/
|
||||
attachFlushHandler(flushHandler) {
|
||||
this.flushHandlers.push(flushHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the `currentFileMeta` to the first incomplete download.
|
||||
* Also sets the `done` property to indicate if all downloads are completed.
|
||||
|
@ -79,7 +104,7 @@ export default class DownloadManager {
|
|||
*/
|
||||
bufferSetup() {
|
||||
/**
|
||||
* IDB put operations have a lot of overhead, so it's impractical for us to store
|
||||
* IDB put operations have a lot of overhead, so it's impractical for us to
|
||||
* a data chunk every time our reader has more data, because those chunks
|
||||
* usually are pretty small and generate thousands of IDB data entries.
|
||||
*
|
||||
|
@ -115,36 +140,71 @@ export default class DownloadManager {
|
|||
fileMeta.done = true;
|
||||
}
|
||||
this.maybePrepareNextFile();
|
||||
this.onflush(fileMeta, fileChunk, this.done);
|
||||
this.flush(fileMeta, fileChunk, this.done);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the first file that is not fully downloaded.
|
||||
*/
|
||||
async downloadFile() {
|
||||
const { bytesDownloaded, url, downloadUrl } = this.currentFileMeta;
|
||||
const fetchOpts = {};
|
||||
const { bytesDownloaded, url } = this.currentFileMeta;
|
||||
|
||||
if (bytesDownloaded) {
|
||||
fetchOpts.headers = {
|
||||
Range: `bytes=${bytesDownloaded}-`,
|
||||
};
|
||||
// Attempts to find an existing response object for the current URL
|
||||
// before fetching the file from the network.
|
||||
let response = this.responses.reduce(
|
||||
(prev, current) => (current.url === url ? current : prev),
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Some URLs we want to download have their offline versions.
|
||||
*
|
||||
* If the current URL is one of those, we want to make sure not to
|
||||
* use any existing response for the original URL.
|
||||
*/
|
||||
const rewrittenUrl = rewriteURL(this.videoId, url, 'online', 'offline');
|
||||
|
||||
if (!response || url !== rewrittenUrl) {
|
||||
const fetchOpts = {};
|
||||
|
||||
if (bytesDownloaded) {
|
||||
fetchOpts.headers = {
|
||||
Range: `bytes=${bytesDownloaded}-`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
response = await fetch(rewrittenUrl, fetchOpts);
|
||||
} catch (e) {
|
||||
this.warning(`Pausing the download of ${rewrittenUrl} due to network error.`);
|
||||
this.forcePause();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, fetchOpts);
|
||||
const reader = response.body.getReader();
|
||||
const mimeType = response.headers.get('Content-Type') || getMimeByURL(url);
|
||||
const fileLength = response.headers.has('Content-Range')
|
||||
? Number(response.headers.get('Content-Range').replace(/^[^/]\/(.*)$/, '$1'))
|
||||
: Number(response.headers.get('Content-Length'));
|
||||
|
||||
// If this is a full response, throw away any bytes downloaded earlier.
|
||||
if (!response.headers.has('Content-Range')) {
|
||||
this.currentFileMeta.bytesDownloaded = 0;
|
||||
}
|
||||
|
||||
this.currentFileMeta.mimeType = mimeType;
|
||||
this.currentFileMeta.bytesTotal = fileLength > 0 ? fileLength : null;
|
||||
|
||||
let dataChunk;
|
||||
do {
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
dataChunk = await reader.read();
|
||||
try {
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
dataChunk = await reader.read();
|
||||
} catch (e) {
|
||||
this.warning(`Pausing the download of ${rewrittenUrl} due to network error.`);
|
||||
this.forcePause();
|
||||
}
|
||||
|
||||
if (!dataChunk.done) this.buffer.add(dataChunk.value);
|
||||
} while (dataChunk && !dataChunk.done && !this.paused);
|
||||
|
@ -160,6 +220,30 @@ export default class DownloadManager {
|
|||
this.paused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the current download and forces the associated
|
||||
* `VideoDownloader` instance to render the paused UI, too.
|
||||
*/
|
||||
forcePause() {
|
||||
this.pause();
|
||||
|
||||
if (document) {
|
||||
const pauseEvent = new CustomEvent('pausedownload', { detail: this.videoId });
|
||||
document.dispatchEvent(pauseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a warning message.
|
||||
*
|
||||
* @todo Update to expose the warning to the user.
|
||||
* @param {string} message Error message.
|
||||
*/
|
||||
warning(message) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download.
|
||||
*/
|
||||
|
@ -168,10 +252,30 @@ export default class DownloadManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Starts downloading files.
|
||||
* Generates a list of URLs to be downloaded and turns them into
|
||||
* a list of FileMeta objects that track download properties
|
||||
* for each of the files.
|
||||
*
|
||||
* @param {string[]} [urls] Optional list of URLs to be downloaded.
|
||||
* @returns {Promise<FileMeta[]>} Promise resolving with FileMeta objects prepared for download.
|
||||
*/
|
||||
async run() {
|
||||
async prepareFileMeta(urls = null) {
|
||||
this.files = await getFileMetaForDownload(this.videoId, urls);
|
||||
return this.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts downloading files.
|
||||
*
|
||||
* @param {Response[]} [responses] Already prepared responses for (some of) the donwloaded
|
||||
* files, e.g. produced by Background Fetch API.
|
||||
*/
|
||||
async run(responses = []) {
|
||||
this.paused = false;
|
||||
this.responses = responses;
|
||||
|
||||
this.maybePrepareNextFile();
|
||||
|
||||
while (!this.done && !this.paused && !this.cancelled && this.currentFileMeta) {
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
await this.downloadFile();
|
||||
|
|
|
@ -207,8 +207,10 @@ export default () => {
|
|||
* has changed.
|
||||
*/
|
||||
dispatchDataChangedEvent() {
|
||||
const changeEvent = new Event(IDB_DATA_CHANGED_EVENT);
|
||||
window.dispatchEvent(changeEvent);
|
||||
if (typeof window !== 'undefined') {
|
||||
const changeEvent = new Event(IDB_DATA_CHANGED_EVENT);
|
||||
window.dispatchEvent(changeEvent);
|
||||
}
|
||||
}
|
||||
|
||||
unwrap() {
|
||||
|
@ -223,6 +225,8 @@ export default () => {
|
|||
/**
|
||||
* Removes all entries from the database used for video storage.
|
||||
*
|
||||
* Doesn't clear the Background Fetch API cache.
|
||||
*
|
||||
* @returns {Promise} Promise that resolves when the DB is deleted.
|
||||
*/
|
||||
abstractedIDB.clearAll = () => new Promise((resolve, reject) => {
|
||||
|
@ -375,11 +379,8 @@ export default () => {
|
|||
* - bytesDownloaded (number) How many bytes of data is already downloaded.
|
||||
* - bytesTotal (number) Total size of the file in bytes.
|
||||
* - done (boolean) Whether the file is fully downloaded.
|
||||
* - downloadUrl (string) The URL used by the application to download file data.
|
||||
* - mimeType (string) File MIME type.
|
||||
* - url (string) The remote URL representing the file. Can be different from
|
||||
* `downloadUrl` in some cases, e.g. when we alter the DASH
|
||||
* manifest to only contain a single video and audio source.
|
||||
* - url (string) The remote URL.
|
||||
* - videoId (string) Video ID this file is assigned to.
|
||||
*/
|
||||
const fileOS = db.createObjectStore(
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import '../typedefs';
|
||||
import iso8601TimeDurationToSeconds from './Duration';
|
||||
import selectRepresentations from '../utils/selectRepresentations';
|
||||
|
||||
/**
|
||||
* Replaces MPD variables in the chunk URL string with proper values.
|
||||
|
@ -205,10 +204,9 @@ export default class {
|
|||
/**
|
||||
* Returns a list of all chunk files referenced in the manifest.
|
||||
*
|
||||
* @param {Array[]} additionalFileTuples List of tuples in the format [fileId, URL].
|
||||
* @returns {string[]} List of all chunk files referenced in the manifest.
|
||||
* @returns {string[]} List of chunk file URLs referenced in the manifest.
|
||||
*/
|
||||
listAllChunkURLs(additionalFileTuples = [[]]) {
|
||||
listAllChunkURLs() {
|
||||
const repObjects = [...this.internal.root.querySelectorAll('Representation')].map(representationElementToObject);
|
||||
const initialSegmentFiles = repObjects.map(getInitialSegment);
|
||||
|
||||
|
@ -222,10 +220,11 @@ export default class {
|
|||
);
|
||||
|
||||
const prependBaseURL = (filename) => this.baseURL + filename;
|
||||
const fileTuples = [...initialSegmentFiles, ...dataChunkFiles].map(
|
||||
(file) => [prependBaseURL(file), prependBaseURL(file)],
|
||||
const chunkUrls = [...initialSegmentFiles, ...dataChunkFiles].map(
|
||||
(file) => prependBaseURL(file),
|
||||
);
|
||||
return [...fileTuples, ...additionalFileTuples];
|
||||
|
||||
return chunkUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -272,72 +271,4 @@ export default class {
|
|||
|
||||
return representationObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all `Representation` elements other than one for video and optionally
|
||||
* one for audio from the manifest.
|
||||
*
|
||||
* @returns {boolean} Whether the operation succeeded.
|
||||
*/
|
||||
prepareForOffline() {
|
||||
const targetResolutionW = 1280;
|
||||
|
||||
/**
|
||||
* This process is potentially destructive. Clone the root <MPD> element first.
|
||||
*/
|
||||
const RootElementClone = this.internal.root.cloneNode(true);
|
||||
const videoAdaptationSets = [...RootElementClone.querySelectorAll('AdaptationSet[contentType="video"]')];
|
||||
const audioAdaptationSets = [...RootElementClone.querySelectorAll('AdaptationSet[contentType="audio"]')];
|
||||
|
||||
/**
|
||||
* Remove all video and audio Adaptation sets apart from the first ones.
|
||||
*/
|
||||
const videoAS = videoAdaptationSets.shift();
|
||||
const audioAS = audioAdaptationSets.shift();
|
||||
[...videoAdaptationSets, ...audioAdaptationSets].forEach(
|
||||
(as) => as.parentNode.removeChild(as),
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove all but first audio representation from the document.
|
||||
*/
|
||||
if (audioAS) {
|
||||
const audioRepresentations = [...audioAS.querySelectorAll('Representation')];
|
||||
audioRepresentations.shift();
|
||||
audioRepresentations.forEach(
|
||||
(rep) => rep.parentNode.removeChild(rep),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the video representation closest to the target resolution and remove the rest.
|
||||
*/
|
||||
if (videoAS) {
|
||||
const videoRepresentations = selectRepresentations(this).video;
|
||||
if (!videoRepresentations) return false;
|
||||
let candidate;
|
||||
|
||||
videoRepresentations.forEach(
|
||||
(videoRep) => {
|
||||
const satisifiesTarget = Number(videoRep.width) >= targetResolutionW;
|
||||
const lessData = Number(videoRep.bandwidth) < Number(candidate?.bandwidth || Infinity);
|
||||
|
||||
if (satisifiesTarget && lessData) {
|
||||
candidate = videoRep;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!candidate) return false;
|
||||
|
||||
while (videoAS.firstChild) videoAS.removeChild(videoAS.firstChild);
|
||||
videoAS.appendChild(candidate.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything was OK, assign the altered document as root again.
|
||||
*/
|
||||
this.internal.root.innerHTML = RootElementClone.innerHTML;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
import '../typedefs';
|
||||
import getProgress from '../utils/getProgress';
|
||||
import getIDBConnection from './IDBConnection';
|
||||
|
||||
/**
|
||||
|
@ -29,18 +30,18 @@ export default class {
|
|||
/**
|
||||
* Instantiates the storage manager.
|
||||
*
|
||||
* @param {VideoDownloader} videoDownloader The associated video downloader object.
|
||||
* @param {string} videoId Video ID to identify stored data.
|
||||
* @param {object} opts Optional settings.
|
||||
* @param {FileMeta[]} opts.fileMeta File meta objects to observe progress for.
|
||||
*/
|
||||
constructor(videoDownloader) {
|
||||
constructor(videoId, opts = {}) {
|
||||
this.done = false;
|
||||
this.videoId = videoId;
|
||||
this.fileMeta = opts.fileMeta || [];
|
||||
|
||||
this.onerror = () => {};
|
||||
this.onprogress = () => {};
|
||||
this.ondone = () => {};
|
||||
|
||||
this.internal = {
|
||||
videoDownloader,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -71,7 +72,7 @@ export default class {
|
|||
const db = await getIDBConnection();
|
||||
const videoMeta = {
|
||||
done: isDone,
|
||||
videoId: this.internal.videoDownloader.getId(),
|
||||
videoId: this.videoId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const txAbortHandler = (e) => {
|
||||
|
@ -125,8 +126,9 @@ export default class {
|
|||
return new Promise((resolve, reject) => {
|
||||
Promise.all([metaWritePromise, dataWritePromise, fileWritePromise])
|
||||
.then(() => {
|
||||
const percentage = this.internal.videoDownloader.getProgress();
|
||||
this.onprogress(percentage);
|
||||
if (this.fileMeta.length > 0) {
|
||||
this.onprogress(getProgress(this.fileMeta));
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
this.done = true;
|
||||
|
|
|
@ -18,12 +18,34 @@ import '../typedefs';
|
|||
import selectRepresentations from '../utils/selectRepresentations';
|
||||
import getRepresentationMimeString from '../utils/getRepresentationMimeString';
|
||||
|
||||
/**
|
||||
* What maximum percentage of total frames are OK to be dropped for us to still
|
||||
* consider the playback experience acceptable?
|
||||
*/
|
||||
const HEALTHY_PERCENTAGE_OF_DROPPED_FRAMES = 0;
|
||||
|
||||
/**
|
||||
* How many samples of dropped frames percentages do we consider when determining
|
||||
* whether the playback experience is acceptable or not.
|
||||
*/
|
||||
const DROPPED_FRAMES_PERCENTAGE_SAMPLE_SIZE = 3;
|
||||
|
||||
/**
|
||||
* How often should the video quality metrics be sampled in miliseconds.
|
||||
*/
|
||||
const VIDEO_QUALITY_SAMPLING_RATE = 1000;
|
||||
|
||||
/**
|
||||
* Streams raw media data to the <video> element through a MediaSource container.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
|
||||
*/
|
||||
export default class {
|
||||
/**
|
||||
* @param {HTMLVideoElement} videoEl Video element.
|
||||
* @param {ParserMPD} parser MPD parser instance.
|
||||
* @param {object} opts Options.
|
||||
*/
|
||||
constructor(videoEl, parser, opts = {}) {
|
||||
this.videoEl = videoEl;
|
||||
this.parser = parser;
|
||||
|
@ -33,7 +55,7 @@ export default class {
|
|||
media: {
|
||||
baseURL: '/',
|
||||
streamTypes: [],
|
||||
representations: selectRepresentations(parser, opts),
|
||||
representations: [],
|
||||
lastRepresentationsIds: {},
|
||||
lastRepresentationSwitch: 0,
|
||||
duration: this.parser.duration,
|
||||
|
@ -52,10 +74,17 @@ export default class {
|
|||
value: null,
|
||||
entries: [],
|
||||
},
|
||||
videoQuality: {
|
||||
last: {
|
||||
totalFrames: 0,
|
||||
droppedFrames: 0,
|
||||
},
|
||||
droppedPercentages: [],
|
||||
intervalId: null,
|
||||
enforcedBandwidthSmallerThan: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
};
|
||||
|
||||
// Iterate which stream types we have separate representations for.
|
||||
this.stream.media.streamTypes = Object.keys(this.stream.media.representations);
|
||||
this.stream.media.baseURL = parser.baseURL;
|
||||
|
||||
/**
|
||||
|
@ -96,10 +125,22 @@ export default class {
|
|||
this.initializeStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Figures out which representations are best to be used on a current device.
|
||||
*/
|
||||
async initializeRepresentations() {
|
||||
this.stream.media.representations = await selectRepresentations(this.parser, this.opts);
|
||||
|
||||
// Iterate which stream types we have separate representations for.
|
||||
this.stream.media.streamTypes = Object.keys(this.stream.media.representations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all the differents parts of the stream.
|
||||
*/
|
||||
async initializeStream() {
|
||||
await this.initializeRepresentations();
|
||||
|
||||
/**
|
||||
* Create and attach a `MediaSource` element.
|
||||
*
|
||||
|
@ -168,6 +209,15 @@ export default class {
|
|||
this.unthrottleBuffer();
|
||||
this.bufferAhead();
|
||||
});
|
||||
|
||||
// Sample when media is playing back.
|
||||
this.videoEl.addEventListener('play', this.startVideoQualitySampling.bind(this));
|
||||
|
||||
// Don't sample when media stalls for any reason.
|
||||
this.videoEl.addEventListener('ended', this.stopVideoQualitySampling.bind(this));
|
||||
this.videoEl.addEventListener('emptied', this.stopVideoQualitySampling.bind(this));
|
||||
this.videoEl.addEventListener('pause', this.stopVideoQualitySampling.bind(this));
|
||||
this.videoEl.addEventListener('waiting', this.stopVideoQualitySampling.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -208,11 +258,40 @@ export default class {
|
|||
const lastSwitchedBefore = Date.now() - this.stream.media.lastRepresentationSwitch;
|
||||
const lockRepresentationFor = 5000;
|
||||
|
||||
if (lastSwitchedBefore < lockRepresentationFor) {
|
||||
const lastVideoRepId = this.stream.media.lastRepresentationsIds.video;
|
||||
representations.video = this.stream.media.representations.video.find(
|
||||
(videoObj) => videoObj.id === lastVideoRepId,
|
||||
const lastVideoRepId = this.stream.media.lastRepresentationsIds.video;
|
||||
const lastVideoRepresentation = this.stream.media.representations.video.find(
|
||||
(videoObj) => videoObj.id === lastVideoRepId,
|
||||
);
|
||||
|
||||
const droppedPercentageMin = this.stream.videoQuality.droppedPercentages.length > 0
|
||||
? Math.min(...this.stream.videoQuality.droppedPercentages)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Playback is healthy when the percentage of dropped frames is not constantly
|
||||
* higher than our threshold.
|
||||
*
|
||||
* We also consider the playback healthy if we don't have enough `videoQuality` data yet.
|
||||
*/
|
||||
const isPlaybackHealthy = (
|
||||
droppedPercentageMin <= HEALTHY_PERCENTAGE_OF_DROPPED_FRAMES
|
||||
|| this.stream.videoQuality.droppedPercentages.length < DROPPED_FRAMES_PERCENTAGE_SAMPLE_SIZE
|
||||
);
|
||||
|
||||
/**
|
||||
* If the playback is not healthy, enforce the next representation bandwidth
|
||||
* to be smaller than the current one and clear video quality data.
|
||||
*/
|
||||
if (!isPlaybackHealthy && lastVideoRepresentation) {
|
||||
this.stream.videoQuality.enforcedBandwidthSmallerThan = parseInt(
|
||||
lastVideoRepresentation.bandwidth,
|
||||
10,
|
||||
);
|
||||
this.stream.videoQuality.droppedPercentages = [];
|
||||
}
|
||||
|
||||
if (lastSwitchedBefore < lockRepresentationFor) {
|
||||
representations.video = lastVideoRepresentation;
|
||||
return representations;
|
||||
}
|
||||
|
||||
|
@ -223,11 +302,36 @@ export default class {
|
|||
usedBandwidth += parseInt(representations.audio.bandwidth || 200000, 10);
|
||||
}
|
||||
|
||||
let candidateVideoRepresentations = this.stream.media.representations.video.filter(
|
||||
(representation) => (
|
||||
parseInt(
|
||||
representation.bandwidth,
|
||||
10,
|
||||
) < this.stream.videoQuality.enforcedBandwidthSmallerThan
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* If no video representations fit the allowed bandwidth, we need to only pick
|
||||
* the one with the smallest bitrate.
|
||||
*/
|
||||
if (candidateVideoRepresentations.length === 0) {
|
||||
candidateVideoRepresentations = [
|
||||
this.stream.media.representations.video.reduce(
|
||||
(carry, representation) => (
|
||||
parseInt(carry.bandwidth, 10) < parseInt(representation.bandwidth, 10)
|
||||
? carry
|
||||
: representation
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* From all video representations, pick the best quality one that
|
||||
* still fits the available bandwidth.
|
||||
*/
|
||||
representations.video = this.stream.media.representations.video.reduce(
|
||||
representations.video = candidateVideoRepresentations.reduce(
|
||||
(previous, current) => {
|
||||
const currentBandwidth = parseInt(current.bandwidth, 10);
|
||||
const previousBandwidth = parseInt(previous.bandwidth, 10);
|
||||
|
@ -241,8 +345,8 @@ export default class {
|
|||
if (previousDoesntFitDownlink && currentIsLessData) return current;
|
||||
return previous;
|
||||
},
|
||||
this.stream.media.representations.video[0],
|
||||
);
|
||||
|
||||
return representations;
|
||||
}
|
||||
|
||||
|
@ -539,4 +643,68 @@ export default class {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts video quality sampling using the Video Playback Quality API.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
startVideoQualitySampling() {
|
||||
if (this.stream.videoQuality.intervalId) return;
|
||||
this.stream.videoQuality.intervalId = setInterval(
|
||||
this.sampleVideoQuality.bind(this), VIDEO_QUALITY_SAMPLING_RATE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video quality sampling using the Video Playback Quality API.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
stopVideoQualitySampling() {
|
||||
if (!this.stream.videoQuality.intervalId) return;
|
||||
clearInterval(this.stream.videoQuality.intervalId);
|
||||
this.stream.videoQuality.intervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the video playback quality metrics provided by Video Playback Quality API
|
||||
* and store the current numbers.
|
||||
*/
|
||||
sampleVideoQuality() {
|
||||
const vq = this.videoEl.getVideoPlaybackQuality();
|
||||
|
||||
/**
|
||||
* In case the internal counter used by `getVideoPlaybackQuality` resets
|
||||
* for any reason, we need to make sure our latest numbers are never
|
||||
* higher than what we're returned.
|
||||
*/
|
||||
const droppedFramesSinceLastSample = (
|
||||
vq.droppedVideoFrames >= this.stream.videoQuality.last.droppedFrames
|
||||
)
|
||||
? vq.droppedVideoFrames - this.stream.videoQuality.last.droppedFrames
|
||||
: vq.droppedVideoFrames;
|
||||
|
||||
const totalFramesSinceLastSample = (
|
||||
vq.totalVideoFrames >= this.stream.videoQuality.last.totalFrames
|
||||
)
|
||||
? vq.totalVideoFrames - this.stream.videoQuality.last.totalFrames
|
||||
: vq.totalVideoFrames;
|
||||
|
||||
this.stream.videoQuality.last = {
|
||||
droppedFrames: vq.droppedVideoFrames,
|
||||
totalFrames: vq.totalVideoFrames,
|
||||
};
|
||||
|
||||
if (totalFramesSinceLastSample > 0) {
|
||||
this.stream.videoQuality.droppedPercentages.push(
|
||||
(droppedFramesSinceLastSample) / totalFramesSinceLastSample,
|
||||
);
|
||||
}
|
||||
|
||||
const droppedPercentagesCount = this.stream.videoQuality.droppedPercentages.length;
|
||||
if (droppedPercentagesCount > DROPPED_FRAMES_PERCENTAGE_SAMPLE_SIZE) {
|
||||
this.stream.videoQuality.droppedPercentages.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,10 @@ export default class VideoDownloaderRegistry {
|
|||
constructor({ connectionStatus }) {
|
||||
this.instances = new Map();
|
||||
this.connectionStatus = connectionStatus;
|
||||
|
||||
if (document) {
|
||||
document.addEventListener('pausedownload', this.onPauseDownload.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -67,4 +71,18 @@ export default class VideoDownloaderRegistry {
|
|||
destroyAll() {
|
||||
this.instances.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* When a pause request is received, we need to pause the download.
|
||||
*
|
||||
* @param {CustomEvent} e Pause event.
|
||||
* @param {string} e.detail Video ID.
|
||||
*/
|
||||
onPauseDownload(e) {
|
||||
const downloaderInstance = this.get(e.detail);
|
||||
|
||||
if (downloaderInstance) {
|
||||
downloaderInstance.downloading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
* There may be adjacent caches used for other purposes and we
|
||||
* want to let the SW know which caches it should purge on upgrade.
|
||||
*/
|
||||
export const SW_CACHE_NAME = 'static-assets-v1.0.0-beta4';
|
||||
export const SW_CACHE_NAME = 'static-assets-v1.0.0-beta5';
|
||||
export const SW_CACHE_FORMAT = /^static-assets-v[a-z0-9.-]+$/;
|
||||
|
||||
/**
|
||||
|
@ -119,8 +119,28 @@ export const ALL_STREAM_TYPES = ['audio', 'video'];
|
|||
*/
|
||||
export const SETTING_KEY_TOGGLE_OFFLINE = 'toggle-offline';
|
||||
export const SETTING_KEY_DARK_MODE = 'dark-mode';
|
||||
export const SETTING_KEY_BG_FETCH_API = 'allow-bg-fetch-api';
|
||||
|
||||
/**
|
||||
* Event name signalling that data in IDB has changes.
|
||||
*/
|
||||
export const IDB_DATA_CHANGED_EVENT = 'idb-data-changed';
|
||||
|
||||
/**
|
||||
* Picture in picture.
|
||||
*/
|
||||
export const PIP_CLASSNAME = 'picture-in-picture';
|
||||
|
||||
/**
|
||||
* Casting.
|
||||
*/
|
||||
export const CAST_CLASSNAME = 'cast';
|
||||
export const CAST_HAS_TARGET_NAME = 'cast-has-target';
|
||||
export const CAST_TARGET_NAME = 'cast-target-name';
|
||||
export const CAST_BUTTON_HIDDEN_CLASSNAME = 'hidden';
|
||||
|
||||
/**
|
||||
* Stats overlay.
|
||||
*/
|
||||
export const STATS_OVERLAY_CLASSNAME = 'stats-overlay';
|
||||
export const STATS_OVERLAY_DISPLAYED_CLASSNAME = 'stats-overlay-visible';
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SETTING_KEY_DARK_MODE } from '../constants';
|
||||
import { SETTING_KEY_DARK_MODE, SETTING_KEY_BG_FETCH_API } from '../constants';
|
||||
|
||||
/**
|
||||
* @param {RouterContext} routerContext Context object passed by the Router.
|
||||
|
@ -24,6 +24,20 @@ export default (routerContext) => {
|
|||
mainContent,
|
||||
connectionStatus,
|
||||
} = routerContext;
|
||||
|
||||
const backgroundFetchSettingMarkup = !('BackgroundFetchManager' in window)
|
||||
? ''
|
||||
: `<div class="settings--option">
|
||||
<div class="setting--option__icon">
|
||||
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" clip-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"><path d="M12 4.34v11.127M16.081 19.66H7.919M7.919 11.386 12 15.467M16.081 11.386 12 15.467" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="setting--option__title">Download videos in the background</p>
|
||||
<p class="setting--option__desc">Use Background Fetch API to download videos in the background.</p>
|
||||
</div>
|
||||
<toggle-button setting="${SETTING_KEY_BG_FETCH_API}"></toggle-button>
|
||||
</div>`;
|
||||
|
||||
mainContent.innerHTML = `<div class="container settings">
|
||||
<header class="page-header">
|
||||
<h1>Settings</h1>
|
||||
|
@ -55,6 +69,7 @@ export default (routerContext) => {
|
|||
</div>
|
||||
<toggle-button setting="${SETTING_KEY_DARK_MODE}"></toggle-button>
|
||||
</div>
|
||||
${backgroundFetchSettingMarkup}
|
||||
<!-- <div class="settings--option">-->
|
||||
<!-- <toggle-button></toggle-button>-->
|
||||
<!-- <div>-->
|
||||
|
|
|
@ -18,6 +18,90 @@ import appendVideoToGallery from '../utils/appendVideoToGallery';
|
|||
import VideoPlayer from '../web-components/video-player/VideoPlayer';
|
||||
import { SW_CACHE_NAME } from '../constants';
|
||||
|
||||
/**
|
||||
* Icons.
|
||||
*/
|
||||
import unmuteIcon from '../../images/icons/unmute.svg';
|
||||
import playIcon from '../../images/icons/play.svg';
|
||||
|
||||
/**
|
||||
* Creates a button to be rendered over the video area.
|
||||
*
|
||||
* @param {object} options Button options.
|
||||
* @param {string} options.markup Button markup.
|
||||
* @param {string} options.action Action ID.
|
||||
* @param {Function} options.callback Callback.
|
||||
* @returns {HTMLButtonElement} Created button.
|
||||
*/
|
||||
const createOverlayButton = ({ markup, action, callback }) => {
|
||||
const buttonElement = document.createElement('button');
|
||||
|
||||
buttonElement.classList.add(`action--${action}`);
|
||||
buttonElement.addEventListener('click', callback);
|
||||
buttonElement.innerHTML = markup;
|
||||
|
||||
return buttonElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the VideoDownloader instance for the current video.
|
||||
*
|
||||
* @param {object} videoData Current video data.
|
||||
* @param {object} routerContext Router context object.
|
||||
* @param {VideoDownloaderRegistry} routerContext.videoDownloaderRegistry Downloader registry.
|
||||
* @returns {VideoDownloader} Downloader instance.
|
||||
*/
|
||||
const setupDownloader = (videoData, { videoDownloaderRegistry }) => {
|
||||
let downloader = videoDownloaderRegistry.get(videoData.id);
|
||||
if (!downloader) {
|
||||
downloader = videoDownloaderRegistry.create(videoData.id);
|
||||
downloader.init(videoData, SW_CACHE_NAME);
|
||||
}
|
||||
downloader.setAttribute('expanded', 'true');
|
||||
|
||||
return downloader;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the video poster markup.
|
||||
*
|
||||
* @param {object} videoData Video data.
|
||||
* @param {string} videoData.title Video title.
|
||||
* @param {(string|Array)} videoData.thumbnail Video thumbnail, either a string or an array.
|
||||
* @returns {string} String containing an <img> or <picture> element.
|
||||
*/
|
||||
const getPosterMarkup = ({ title, thumbnail }) => {
|
||||
if (!Array.isArray(thumbnail)) {
|
||||
return `<img src="${thumbnail}" width="1200" height="675" alt="${title}">`;
|
||||
}
|
||||
|
||||
const sources = thumbnail.filter((t) => !t.default).map((t) => `<source srcset="${t.src}" type="${t.type}">`).join('');
|
||||
const defaultSource = thumbnail.find((t) => t.default);
|
||||
const defaultSourceHTML = `<img src="${defaultSource.src}" width="1200" height="675" alt="${title}">`;
|
||||
|
||||
return `<picture>${sources}${defaultSourceHTML}</picture>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the video data returned by the API and extract the current video data.
|
||||
*
|
||||
* @param {object} routerContext Router context.
|
||||
* @param {object} routerContext.apiData All API data.
|
||||
* @param {string} routerContext.path Current path.
|
||||
* @returns {Array} Array containing the current video data and the rest of videos data.
|
||||
*/
|
||||
const extractVideoData = ({ apiData, path }) => apiData.videos.reduce(
|
||||
(returnValue, videoMeta) => {
|
||||
if (path.includes(`/${videoMeta.id}`)) {
|
||||
returnValue[0] = videoMeta;
|
||||
} else {
|
||||
returnValue[1].push(videoMeta);
|
||||
}
|
||||
return returnValue;
|
||||
},
|
||||
[null, []],
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {RouterContext} routerContext Context object passed by the Router.
|
||||
*/
|
||||
|
@ -25,97 +109,157 @@ export default (routerContext) => {
|
|||
const {
|
||||
mainContent,
|
||||
apiData,
|
||||
path,
|
||||
connectionStatus,
|
||||
videoDownloaderRegistry,
|
||||
} = routerContext;
|
||||
|
||||
/**
|
||||
* Pick the current video data out of the `apiData` array
|
||||
* and also return the rest of that data.
|
||||
*/
|
||||
const [currentVideoData, restVideoData] = apiData.videos.reduce(
|
||||
(returnValue, videoMeta) => {
|
||||
if (path.includes(`/${videoMeta.id}`)) {
|
||||
returnValue[0] = videoMeta;
|
||||
} else {
|
||||
returnValue[1].push(videoMeta);
|
||||
}
|
||||
return returnValue;
|
||||
},
|
||||
[null, []],
|
||||
);
|
||||
|
||||
const posterWrapper = document.createElement('div');
|
||||
const { thumbnail } = currentVideoData;
|
||||
|
||||
let downloader = videoDownloaderRegistry.get(currentVideoData.id);
|
||||
if (!downloader) {
|
||||
downloader = videoDownloaderRegistry.create(currentVideoData.id);
|
||||
downloader.init(currentVideoData, SW_CACHE_NAME);
|
||||
}
|
||||
downloader.setAttribute('expanded', 'true');
|
||||
|
||||
const disabled = (connectionStatus.status === 'offline' && downloader.state !== 'done');
|
||||
|
||||
let videoImageHTML;
|
||||
|
||||
if (Array.isArray(thumbnail)) {
|
||||
const sources = thumbnail.filter((t) => !t.default).map((t) => `<source srcset="${t.src}" type="${t.type}">`).join('');
|
||||
const defaultSource = thumbnail.find((t) => t.default);
|
||||
const defaultSourceHTML = `<img src="${defaultSource.src}" width="1200" height="675" alt="${currentVideoData.title}">`;
|
||||
|
||||
videoImageHTML = `<picture>${sources}${defaultSourceHTML}</picture>`;
|
||||
} else {
|
||||
videoImageHTML = `<img src="${currentVideoData.thumbnail}" width="1200" height="675" alt="${currentVideoData.title}">`;
|
||||
}
|
||||
|
||||
const [currentVideoData, restVideoData] = extractVideoData(routerContext);
|
||||
const downloader = setupDownloader(currentVideoData, routerContext);
|
||||
const [videoMinutes, videoSeconds] = currentVideoData.length.split(':');
|
||||
|
||||
/**
|
||||
* Returns whether the current video is available for playback.
|
||||
*
|
||||
* @param {object} downloaderOrState Downloader instance or state object.
|
||||
* @param {string} downloaderOrState.state Downloader state string, e.g. "done".
|
||||
* @param {boolean} downloaderOrState.willremove Downloader willremove flag.
|
||||
* @returns {boolean} Whether video is available for playback from any source (network or IDB).
|
||||
*/
|
||||
const isVideoAvailable = (downloaderOrState) => connectionStatus.status !== 'offline' // We're are not offline...
|
||||
|| downloaderOrState.state === 'done' // ... or we have the video downloaded
|
||||
|| downloaderOrState.willremove === true; // ... or we're about to remove it, but haven't yet.
|
||||
|
||||
mainContent.innerHTML = `
|
||||
<div class="container">
|
||||
<article${disabled ? ' class="video--disabled"' : ''}>
|
||||
<div class="video-container width-full">
|
||||
<div class="video-container--image">
|
||||
${videoImageHTML}
|
||||
<button class="play" aria-label="Play video">
|
||||
<svg width="112" height="112" viewBox="0 0 112 112" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d)">
|
||||
<rect x="24" y="20.4863" width="64" height="64" rx="32" fill="white"/>
|
||||
<path d="M64 52.496C64 53.0015 63.8409 53.5089 63.5227 53.9152C63.4631 53.995 63.1847 54.3235 62.9659 54.5374L62.8466 54.654C61.1761 56.4251 57.0199 59.0885 54.9119 59.942C54.9119 59.9614 53.6591 60.4688 53.0625 60.4863H52.983C52.0682 60.4863 51.2131 59.9828 50.7756 59.1663C50.5369 58.7172 50.3182 57.4146 50.2983 57.3971C50.1193 56.2287 50 54.4402 50 52.4766C50 50.4178 50.1193 48.5495 50.3381 47.4025C50.3381 47.383 50.5568 46.3332 50.696 45.9833C50.9148 45.4798 51.3125 45.0501 51.8097 44.7779C52.2074 44.5855 52.625 44.4863 53.0625 44.4863C53.5199 44.5077 54.375 44.7974 54.7131 44.9335C56.9403 45.7889 61.196 48.5884 62.8267 50.2992C63.1051 50.5714 63.4034 50.9038 63.483 50.9796C63.821 51.4073 64 51.9323 64 52.496Z" fill="#141216"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="0" y="0.486328" width="112" height="112" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="12"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="container">
|
||||
<article class="video-article ${isVideoAvailable(downloader) ? '' : 'video--disabled'}">
|
||||
<div class="video-container width-full">
|
||||
<div class="video-container--overlay"></div>
|
||||
<div class="video-container--player"></div>
|
||||
</div>
|
||||
<div class="video-container--player"></div>
|
||||
</div>
|
||||
<div class="video-content">
|
||||
<h1>${currentVideoData.title}</h1>
|
||||
<div class="info">
|
||||
<span class="date">${currentVideoData.date}</span> • <span class="length">${videoMinutes}min ${videoSeconds}sec</span>
|
||||
<div class="video-content">
|
||||
<h1>${currentVideoData.title}</h1>
|
||||
<div class="info">
|
||||
<span class="date">${currentVideoData.date}</span> • <span class="length">${videoMinutes}min ${videoSeconds}sec</span>
|
||||
</div>
|
||||
<p>${currentVideoData.description}</p>
|
||||
<p><span class="downloader"></span></p>
|
||||
${currentVideoData.body}
|
||||
</div>
|
||||
<p>${currentVideoData.description}</p>
|
||||
<p><span class="downloader"></span></p>
|
||||
${currentVideoData.body}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
mainContent.prepend(posterWrapper);
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
mainContent.querySelector('.downloader').appendChild(downloader);
|
||||
|
||||
const playButton = mainContent.querySelector('.play');
|
||||
const containerEl = mainContent.querySelector('.video-container');
|
||||
const overlayEl = containerEl.querySelector('.video-container--overlay');
|
||||
|
||||
/**
|
||||
* Creates a new video player component that renders a custom
|
||||
* <video-player> element, which encapsulates all logic
|
||||
* around rendering the native <video> element.
|
||||
*
|
||||
* @returns {Promise} Promise that rejects when the video can't be played right now.
|
||||
*/
|
||||
const attachAndPlay = () => {
|
||||
const playerWrapper = containerEl.querySelector('.video-container--player');
|
||||
const videoPlayer = new VideoPlayer(downloader);
|
||||
|
||||
containerEl.classList.add('has-player');
|
||||
videoPlayer.render(currentVideoData);
|
||||
playerWrapper.appendChild(videoPlayer);
|
||||
|
||||
return videoPlayer.play();
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an overlay layer with a button, optionally also adding any custom markup.
|
||||
*
|
||||
* @param {string} options Render options.
|
||||
* @param {string} options.markup Default markup to be rendered as an overlay.
|
||||
* @param {HTMLButtonElement} options.buttonElement Markup to be rendered as an overlay.
|
||||
*/
|
||||
const renderOverlay = ({ buttonElement, markup = '' }) => {
|
||||
overlayEl.innerHTML = markup;
|
||||
overlayEl.appendChild(buttonElement);
|
||||
|
||||
containerEl.classList.add('has-overlay');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the overlay layer.
|
||||
*/
|
||||
const clearOverlay = () => {
|
||||
containerEl.classList.remove('has-overlay');
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the manual play button.
|
||||
*/
|
||||
const setupManualPlayButton = () => {
|
||||
renderOverlay({
|
||||
buttonElement: createOverlayButton({
|
||||
markup: playIcon,
|
||||
action: 'play',
|
||||
callback: async () => {
|
||||
attachAndPlay();
|
||||
clearOverlay();
|
||||
},
|
||||
}),
|
||||
markup: getPosterMarkup(currentVideoData),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the unmute button.
|
||||
*
|
||||
* @param {VideoPlayer} videoPlayer Video player instance.
|
||||
*/
|
||||
const setupUnmuteButton = (videoPlayer) => {
|
||||
renderOverlay({
|
||||
buttonElement: createOverlayButton({
|
||||
markup: `${unmuteIcon} Tap to unmute`,
|
||||
action: 'unmute',
|
||||
callback: () => {
|
||||
videoPlayer.unmute();
|
||||
clearOverlay();
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the autoplay setting.
|
||||
*/
|
||||
if (!currentVideoData.autoplay) {
|
||||
setupManualPlayButton();
|
||||
} else {
|
||||
/**
|
||||
* Error handler for when we are disallowed to play the video right away.
|
||||
*
|
||||
* @param {VideoPlayer} videoPlayer Video player component instance.
|
||||
*/
|
||||
const errHandler = (videoPlayer) => {
|
||||
// If auto playback fails, mute the video and retry.
|
||||
videoPlayer.mute();
|
||||
videoPlayer.play()
|
||||
.then(() => setupUnmuteButton(videoPlayer)) // Can play muted, display unmute button.
|
||||
.catch(setupManualPlayButton); // Can't play automatically at all, setup manual play button.
|
||||
};
|
||||
|
||||
// Attempt autoplay.
|
||||
attachAndPlay().catch(errHandler);
|
||||
}
|
||||
|
||||
downloader.subscribe((oldState, newState) => {
|
||||
const articleEl = mainContent.querySelector('.video-article');
|
||||
|
||||
if (isVideoAvailable(newState)) {
|
||||
articleEl.classList.remove('video--disabled');
|
||||
} else {
|
||||
articleEl.classList.add('video--disabled');
|
||||
}
|
||||
});
|
||||
|
||||
const categorySlug = currentVideoData.categories[0];
|
||||
const { name, slug } = apiData.categories.find((obj) => obj.slug === categorySlug);
|
||||
const localContext = {
|
||||
|
@ -134,15 +278,4 @@ export default (routerContext) => {
|
|||
categories: apiData.categories,
|
||||
},
|
||||
}, localContext);
|
||||
|
||||
playButton.addEventListener('click', (e) => {
|
||||
const videoContainer = e.target.closest('.video-container');
|
||||
const playerWrapper = videoContainer.querySelector('.video-container--player');
|
||||
const videoPlayer = new VideoPlayer();
|
||||
|
||||
videoContainer.classList.add('has-player');
|
||||
videoPlayer.render(currentVideoData);
|
||||
playerWrapper.appendChild(videoPlayer);
|
||||
videoPlayer.play();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -43,4 +43,10 @@ export default [
|
|||
'https://storage.googleapis.com/kino-assets/streaming-basics/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/efficient-formats/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/adaptive-streaming/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/autoplay/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/picture-in-picture/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/google-cast/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/encrypted-media-extensions/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/playback-performance/thumbnail.png',
|
||||
'https://storage.googleapis.com/kino-assets/background-fetch-api/thumbnail.png',
|
||||
];
|
||||
|
|
|
@ -26,6 +26,9 @@ import {
|
|||
|
||||
import getIDBConnection from '../classes/IDBConnection';
|
||||
import assetsToCache from './cache';
|
||||
import BackgroundFetch from '../classes/BackgroundFetch';
|
||||
import DownloadManager from '../classes/DownloadManager';
|
||||
import StorageManager from '../classes/StorageManager';
|
||||
|
||||
/**
|
||||
* Respond to a request to fetch offline video file and construct a response stream.
|
||||
|
@ -217,3 +220,68 @@ const fetchHandler = async (event) => {
|
|||
self.addEventListener('install', precacheAssets);
|
||||
self.addEventListener('activate', clearOldCaches);
|
||||
self.addEventListener('fetch', fetchHandler);
|
||||
|
||||
if ('BackgroundFetchManager' in self) {
|
||||
/**
|
||||
* When Background Fetch API is used to download a file
|
||||
* for offline viewing, Channel Messaging API is used
|
||||
* to signal that a video is fully saved to IDB back
|
||||
* to the UI.
|
||||
*
|
||||
* Because the operation is initiated from the UI, the
|
||||
* `MessageChannel` instance is created there and one of
|
||||
* the newly created channel ports is then sent to the
|
||||
* service worker and stored in this variable.
|
||||
*
|
||||
* @type {MessagePort}
|
||||
*/
|
||||
let messageChannelPort;
|
||||
|
||||
self.addEventListener(
|
||||
'message',
|
||||
(event) => {
|
||||
if (event.data.type === 'channel-port') {
|
||||
[messageChannelPort] = event.ports;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const bgFetchHandler = async (e) => {
|
||||
/** @type {BackgroundFetchRegistration} */
|
||||
const bgFetchRegistration = e.registration;
|
||||
const records = await bgFetchRegistration.matchAll();
|
||||
const urls = records.map((record) => record.request.url);
|
||||
|
||||
if (urls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responsePromises = records.map((record) => record.responseReady);
|
||||
const responses = await Promise.all(responsePromises);
|
||||
const bgFetch = new BackgroundFetch();
|
||||
|
||||
bgFetch.fromRegistration(bgFetchRegistration);
|
||||
|
||||
/**
|
||||
* The `DownloadManager` reads binary data from passed response objects
|
||||
* and routes the data to the `StorageManager` that saves it along with any
|
||||
* metadata to IndexedDB.
|
||||
*/
|
||||
const downloadManager = new DownloadManager(bgFetch.videoId);
|
||||
const storageManager = new StorageManager(bgFetch.videoId);
|
||||
const boundStoreChunkHandler = storageManager.storeChunk.bind(storageManager);
|
||||
|
||||
await downloadManager.prepareFileMeta(urls);
|
||||
downloadManager.attachFlushHandler(boundStoreChunkHandler);
|
||||
downloadManager.attachFlushHandler((fileMeta, fileChunk, isDone) => {
|
||||
// If we have a message channel open, signal back to the UI when we're done.
|
||||
if (isDone && messageChannelPort) {
|
||||
messageChannelPort.postMessage('done');
|
||||
}
|
||||
});
|
||||
|
||||
// Start the download, i.e. pump binary data out of the response objects.
|
||||
downloadManager.run(responses);
|
||||
};
|
||||
self.addEventListener('backgroundfetchsuccess', bgFetchHandler);
|
||||
}
|
||||
|
|
|
@ -22,8 +22,7 @@
|
|||
|
||||
/**
|
||||
* @typedef {object} FileMeta
|
||||
* @property {string} url The original resource URL.
|
||||
* @property {string} downloadUrl Rewritten resource URL.
|
||||
* @property {string} url Resource URL.
|
||||
* @property {string} videoId Identifier for the video this file is associated with.
|
||||
* @property {string} mimeType File MIME type.
|
||||
* @property {number} bytesDownloaded Total bytes downloaded of the resources.
|
||||
|
@ -92,3 +91,52 @@
|
|||
* @property {VideoDownloaderRegistry} VideoDownloaderRegistry Storage for `videoDownload`
|
||||
* instances reuse.
|
||||
*/
|
||||
|
||||
/* eslint-disable max-len */
|
||||
/**
|
||||
* @typedef {object} BackgroundFetchRecord
|
||||
* @property {Request} request Request.
|
||||
* @property {Promise<Response>} responseReady Returns a promise that resolves with a Response.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback BgFetchMatch
|
||||
* @param {Request} request The Request for which you are attempting to find records.
|
||||
* @param {object} options An object that sets options for the match operation.
|
||||
* @returns {BackgroundFetchRecord} Background fetch record.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback BgFetchAbort
|
||||
* @returns {Promise<boolean>} Whether the background fetch was successfully aborted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback BgFetchMatchAll
|
||||
* @param {Request} request The Request for which you are attempting to find records.
|
||||
* @param {object} options An object that sets options for the match operation.
|
||||
* @returns {Promise<BackgroundFetchRecord[]>} Promise that resolves with an array of BackgroundFetchRecord objects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} BackgroundFetchRegistration
|
||||
* @property {string} id containing the background fetch's ID.
|
||||
* @property {number} uploadTotal containing the total number of bytes to be uploaded.
|
||||
* @property {number} uploaded containing the size in bytes successfully sent, initially 0.
|
||||
* @property {number} downloadTotal containing the total size in bytes of this download.
|
||||
* @property {number} downloaded containing the size in bytes that has been downloaded, initially 0.
|
||||
* @property {""|"aborted"|"bad-status"|"fetch-error"|"quota-exceeded"|"download-total-exceeded"} failureReason Failure reason.
|
||||
* @property {boolean} recordsAvailable indicating whether the recordsAvailable flag is set.
|
||||
* @property {BgFetchMatch} match Returns a single BackgroundFetchRecord object which is the first match for the arguments.
|
||||
* @property {BgFetchMatchAll} matchAll Returns a Promise that resolves with an array of BackgroundFetchRecord objects containing requests and responses.
|
||||
* @property {BgFetchAbort} abort Aborts the background fetch. Returns a Promise that resolves with true if the fetch was successfully aborted.
|
||||
*/
|
||||
/* eslint-enable max-len */
|
||||
|
||||
/**
|
||||
* @callback DownloadFlushHandler
|
||||
* @param {FileMeta} fileMeta File meta.
|
||||
* @param {FileChunk} fileChunk File chunk.
|
||||
* @param {boolean} isDone Is this the last downloaded piece?
|
||||
* @returns {void}
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Copyright 2021 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converting a regular MP4 video to an encrypted one that will actually
|
||||
* play in browsers using MSE+EME is a two-step process.
|
||||
*
|
||||
* First, we need to fragment the video if its not fragmented already:
|
||||
*
|
||||
* ```
|
||||
* ffmpeg -i video.mp4 -vcodec copy -acodec copy
|
||||
* -movflags frag_keyframe+empty_moov+default_base_moof fragmented.mp4
|
||||
* ```
|
||||
*
|
||||
* Then, we need to encrypt the fragmented video and make sure the encrypted MP4
|
||||
* contains the PSSH atom. We use Bento4's `mp4encrypt` tool to do this:
|
||||
*
|
||||
* ```
|
||||
* mp4encrypt
|
||||
* --method MPEG-CENC
|
||||
* --key 1:eecdb2b549f02a7c97ce50c17f494ca0:random
|
||||
* --property 1:KID:c77fee35e51fd615a7b91afcb1091c5e
|
||||
* --key 2:9abb7ab6cc4ad3b86c2193dadb1e786c:random
|
||||
* --property 2:KID:045f7ecc35848ed7b3c012ea7614422f
|
||||
* --global-option mpeg-cenc.eme-pssh:true fragmented.mp4 encrypted.mp4
|
||||
* ```
|
||||
*
|
||||
* This example expects the media file to contain exactly two tracks. In our case
|
||||
* track `1` is the video, track `2` the audio.
|
||||
*
|
||||
* Substitute your own keys and KIDs – 32 hex characters each.
|
||||
*
|
||||
* {@link https://ffmpeg.org/}
|
||||
* {@link https://www.bento4.com/documentation/mp4encrypt/}
|
||||
*/
|
||||
|
||||
/**
|
||||
* KID:key pairs as non-padded base64 values.
|
||||
*/
|
||||
const KEYS = {
|
||||
x3_uNeUf1hWnuRr8sQkcXg: '7s2ytUnwKnyXzlDBf0lMoA',
|
||||
'BF9-zDWEjtezwBLqdhRCLw': 'mrt6tsxK07hsIZPa2x54bA',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a license for the given key IDs ("kids").
|
||||
*
|
||||
* @param {Uint8Array} message Key session message.
|
||||
* @returns {Uint8Array} License data.
|
||||
*/
|
||||
const generateLicense = (message) => {
|
||||
const request = JSON.parse(new TextDecoder().decode(message));
|
||||
const keys = [];
|
||||
|
||||
request.kids.forEach((kid) => {
|
||||
keys.push({
|
||||
kty: 'oct',
|
||||
alg: 'A128KW',
|
||||
kid,
|
||||
k: KEYS[kid],
|
||||
});
|
||||
});
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify({
|
||||
keys,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses Encrypted Media Extensions to decrypt the video.
|
||||
*
|
||||
* @param {HTMLMediaElement} videoElement Video element.
|
||||
* @param {object} encryption Encryption data.
|
||||
* @param {string} encryption.src Encrypted video source URL.
|
||||
* @param {string} encryption.type Encryption type.
|
||||
* @param {string} encryption.mimeCodec Media MIME type and codec string.
|
||||
* @param {object} encryption.mediaKeySystemConfig Key system config.
|
||||
* @param {object} [encryption.key] Encryption key.
|
||||
* @param {string} [encryption.key.id] Key ID as a hexadecimal string.
|
||||
* @param {string} [encryption.key.value] Key value as a hexadecimal string.
|
||||
* @returns {void}
|
||||
*/
|
||||
export default function decryptVideo(videoElement, encryption) {
|
||||
const handleEncrypted = async ({ initDataType, initData }) => {
|
||||
const mediaKeySystemAccess = await navigator.requestMediaKeySystemAccess(
|
||||
encryption.type,
|
||||
[encryption.mediaKeySystemConfig],
|
||||
);
|
||||
const mediaKeys = await mediaKeySystemAccess.createMediaKeys();
|
||||
await videoElement.setMediaKeys(mediaKeys);
|
||||
|
||||
const keySession = mediaKeys.createSession();
|
||||
keySession.addEventListener('message', async (e) => {
|
||||
const license = generateLicense(e.message);
|
||||
await keySession.update(license).catch(
|
||||
/* eslint-disable-next-line no-console */
|
||||
(updateError) => console.error(`keySession.update() failed with error: ${updateError}`),
|
||||
);
|
||||
}, false);
|
||||
await keySession.generateRequest(initDataType, initData);
|
||||
};
|
||||
|
||||
videoElement.addEventListener('encrypted', handleEncrypted, false);
|
||||
|
||||
if ('MediaSource' in window && MediaSource.isTypeSupported(encryption.mimeCodec)) {
|
||||
const mediaSource = new MediaSource();
|
||||
videoElement.src = URL.createObjectURL(mediaSource);
|
||||
|
||||
mediaSource.addEventListener('sourceopen', () => {
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(encryption.mimeCodec);
|
||||
|
||||
fetch(encryption.src).then((response) => {
|
||||
response.arrayBuffer().then((arrayBuffer) => {
|
||||
sourceBuffer.addEventListener(
|
||||
'updateend',
|
||||
() => {
|
||||
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
|
||||
mediaSource.endOfStream();
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
sourceBuffer.appendBuffer(arrayBuffer);
|
||||
});
|
||||
})
|
||||
/* eslint-disable-next-line no-console */
|
||||
.catch((e) => console.error(`Encrypted media fetch failed with error: ${e}`));
|
||||
});
|
||||
} else {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error('Unsupported MIME type or codec: ', encryption.mimeCodec);
|
||||
}
|
||||
}
|
|
@ -68,7 +68,7 @@ const generateApiData = async () => {
|
|||
// eslint-disable-next-line
|
||||
for await (const file of getFiles(apiSrcPath)) {
|
||||
const fileName = path.basename(file);
|
||||
const categoryName = path.basename(path.dirname(file));
|
||||
const categoryName = path.basename(path.dirname(file)).replace(/^[0-9]+-/, '');
|
||||
const textData = fs.readFileSync(file, 'utf8');
|
||||
const fmContent = frontMatter(textData);
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Returns decoding info for a given media configuration.
|
||||
*
|
||||
* @param {MediaDecodingConfiguration} mediaConfig Media configuration.
|
||||
* @returns {Promise<MediaCapabilitiesInfo>} Media decoding information.
|
||||
*/
|
||||
export default async function getDecodingInfo(mediaConfig) {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
if (!('mediaCapabilities' in navigator)) {
|
||||
return reject('MediaCapabilities API not available');
|
||||
}
|
||||
if (!('decodingInfo' in navigator.mediaCapabilities)) {
|
||||
return reject('Decoding Info not available');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line compat/compat
|
||||
return resolve(navigator.mediaCapabilities.decodingInfo(mediaConfig));
|
||||
});
|
||||
|
||||
return promise.catch(() => {
|
||||
const fallbackResult = {
|
||||
supported: false,
|
||||
smooth: false, // always false
|
||||
powerEfficient: false, // always false
|
||||
};
|
||||
|
||||
if ('video' in mediaConfig) {
|
||||
fallbackResult.supported = MediaSource.isTypeSupported(mediaConfig.video.contentType);
|
||||
if (!fallbackResult.supported) {
|
||||
return fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
if ('audio' in mediaConfig) {
|
||||
fallbackResult.supported = MediaSource.isTypeSupported(mediaConfig.audio.contentType);
|
||||
}
|
||||
return fallbackResult;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Copyright 2021 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '../typedefs';
|
||||
import getURLsForDownload from './getURLsForDownload';
|
||||
import getIDBConnection from '../classes/IDBConnection';
|
||||
|
||||
/**
|
||||
* Returns a list of URLs that need to be downloaded in order to allow
|
||||
* offline playback of this resource on this device.
|
||||
*
|
||||
* @param {string} videoId Video ID.
|
||||
* @param {string[]} [urls] Optionally a list of URLs to be downloaded.
|
||||
* @returns {Promise<FileMeta[]>} List of FileMeta objects associated with the given `videoId`.
|
||||
*/
|
||||
export default async (videoId, urls = null) => {
|
||||
const db = await getIDBConnection();
|
||||
const dbFiles = await db.file.getByVideoId(videoId);
|
||||
const dbFilesUrlTuples = dbFiles.map((fileMeta) => [fileMeta.url, fileMeta]);
|
||||
const dbFilesByUrl = Object.fromEntries(dbFilesUrlTuples);
|
||||
|
||||
if (!urls) {
|
||||
urls = await getURLsForDownload(videoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we have an entry for this file in the database, use it. Otherwise
|
||||
* fall back to the freshly generated FileMeta object.
|
||||
*/
|
||||
return urls.map(
|
||||
(fileUrl) => (dbFilesByUrl[fileUrl] ? dbFilesByUrl[fileUrl] : {
|
||||
url: fileUrl,
|
||||
videoId,
|
||||
mimeType: 'application/octet-stream', // Filled in by `DownloadManager` later.
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: null, // Filled in by `DownloadManager` later.
|
||||
done: false,
|
||||
}),
|
||||
);
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
import getRepresentationMimeString from './getRepresentationMimeString';
|
||||
|
||||
/**
|
||||
* Returns a Media configuration object for a given
|
||||
* stream video representation.
|
||||
*
|
||||
* @param {object} representation MPEG DASH representation data.
|
||||
* @param {string} representation.bandwidth Video bandwidth.
|
||||
* @param {string} representation.codecs Codecs string.
|
||||
* @param {string} representation.frameRate Framerate info.
|
||||
* @param {string} representation.height Video height in pixels.
|
||||
* @param {string} representation.mimeType Video MIME.
|
||||
* @param {string} representation.width Video width in pixels.
|
||||
* @returns {MediaConfiguration} Media configuration object.
|
||||
*/
|
||||
export function getMediaConfigurationVideo(representation) {
|
||||
/**
|
||||
* Framerate can be specified as `frameRate: "24000/1001"` in the representation.
|
||||
*
|
||||
* We need to convert it to a float.
|
||||
*/
|
||||
const framerateChunks = representation.frameRate.split('/');
|
||||
let framerate = parseFloat(representation.frameRate);
|
||||
|
||||
if (framerateChunks.length === 2) {
|
||||
framerate = parseFloat(framerateChunks[0]) / parseFloat(framerateChunks[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'media-source',
|
||||
video: {
|
||||
contentType: getRepresentationMimeString(representation),
|
||||
width: parseInt(representation.width, 10),
|
||||
height: parseInt(representation.height, 10),
|
||||
bitrate: parseInt(representation.bandwidth, 10),
|
||||
framerate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Media configuration object for a given
|
||||
* stream audio representation.
|
||||
*
|
||||
* @param {object} representation MPEG DASH representation data.
|
||||
* @param {string} representation.bandwidth Audio bandwidth.
|
||||
* @param {string} representation.codecs Codecs string.
|
||||
* @param {string} representation.audioSamplingRate Audio sampling rate.
|
||||
* @param {string} representation.mimeType Audio MIME.
|
||||
* @param {Element} representation.element Representation XML node.
|
||||
* @returns {MediaConfiguration} Media configuration object.
|
||||
*/
|
||||
export function getMediaConfigurationAudio(representation) {
|
||||
/**
|
||||
* Sensible default, but we'll try to find out the actual value.
|
||||
*/
|
||||
let channels = 2;
|
||||
|
||||
if (representation.element) {
|
||||
const acc = representation.element.querySelector('AudioChannelConfiguration');
|
||||
|
||||
if (acc) {
|
||||
channels = parseInt(acc.getAttribute('value'), 10);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'media-source',
|
||||
audio: {
|
||||
contentType: getRepresentationMimeString(representation),
|
||||
bitrate: parseInt(representation.bandwidth, 10),
|
||||
samplerate: parseInt(representation.audioSamplingRate, 10),
|
||||
channels,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Returns the total download progress for the video.
|
||||
*
|
||||
* @param {FileMeta[]} fileMetas File meta objects to calculate progress for.
|
||||
* @returns {number} Percentage progress for the video in the range 0–100.
|
||||
*/
|
||||
export default function getProgress(fileMetas) {
|
||||
const pieceValue = 1 / fileMetas.length;
|
||||
const percentageProgress = fileMetas.reduce(
|
||||
(percentage, fileMeta) => {
|
||||
if (fileMeta.done) {
|
||||
percentage += pieceValue;
|
||||
} else if (fileMeta.bytesDownloaded === 0 || !fileMeta.bytesTotal) {
|
||||
percentage += 0;
|
||||
} else {
|
||||
const percentageOfCurrent = fileMeta.bytesDownloaded / fileMeta.bytesTotal;
|
||||
percentage += percentageOfCurrent * pieceValue;
|
||||
}
|
||||
return percentage;
|
||||
},
|
||||
0,
|
||||
);
|
||||
const clampedPercents = Math.max(0, Math.min(percentageProgress, 1));
|
||||
|
||||
return clampedPercents;
|
||||
}
|
|
@ -17,73 +17,53 @@
|
|||
import '../typedefs';
|
||||
import ParserMPD from '../classes/ParserMPD';
|
||||
import selectSource from './selectSource';
|
||||
import getVideoSources from './getVideoSources';
|
||||
import rewriteURL from './rewriteURL';
|
||||
|
||||
/**
|
||||
* Returns a list of URLs that need to be downloaded in order to allow
|
||||
* offline playback of this resource on this device.
|
||||
*
|
||||
* @param {string} videoId Video ID.
|
||||
* @param {object[]} sources Video sources.
|
||||
* @returns {Promise<FileMeta[]>} Promise resolving to file meta objects.
|
||||
* @param {string} videoId Video ID.
|
||||
* @returns {Promise<string[]>} List of URLs associated with the given `videoId`.
|
||||
*/
|
||||
export default async (videoId, sources) => {
|
||||
let URLTuples = [];
|
||||
const selectedSource = selectSource(sources);
|
||||
export default async (videoId) => {
|
||||
const videoSources = getVideoSources(videoId);
|
||||
const selectedSource = selectSource(videoSources);
|
||||
|
||||
if (selectedSource === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let urls = [];
|
||||
|
||||
/**
|
||||
* If this is a streamed video, we need to read the manifest
|
||||
* first and generate a list of files to be downloaded.
|
||||
*/
|
||||
if (selectedSource?.canPlayTypeMSE) {
|
||||
const response = await fetch(selectedSource.src);
|
||||
const responseText = await response.text();
|
||||
const parser = new ParserMPD(responseText, selectedSource.src);
|
||||
const offlineManifestUrl = rewriteURL(videoId, selectedSource.src, 'online', 'offline');
|
||||
|
||||
/**
|
||||
* This removes all but one audio and video representations from the manifest.
|
||||
* Use the offline version of the manifest to make sure we only line up
|
||||
* a subset of all available media data for download.
|
||||
*
|
||||
* We don't want to download video files in all possible resolutions and formats.
|
||||
* Instead the offline manifest only contain one manually selected representation.
|
||||
*/
|
||||
const offlineGenerated = parser.prepareForOffline();
|
||||
if (offlineGenerated) {
|
||||
/**
|
||||
* The manifest data has been changed in memory. We need to persist the changes.
|
||||
* Generating a data URI gives us a stable faux-URL that can reliably be used in
|
||||
* place of a real URL and even stored in the database for later usage.
|
||||
*
|
||||
* Note: the offline manifest file is pretty small in size (several kBs max),
|
||||
* making it a good candidate for a data URI.
|
||||
*/
|
||||
const offlineManifestDataURI = parser.toDataURI();
|
||||
try {
|
||||
const response = await fetch(offlineManifestUrl);
|
||||
const responseText = await response.text();
|
||||
const parser = new ParserMPD(responseText, selectedSource.src);
|
||||
|
||||
/**
|
||||
* For most files, the `url` and `downloadUrl` are the same thing – see
|
||||
* `listAllChunkURLs` source code.
|
||||
*
|
||||
* The only exception are the manifest files, where the `downloadUrl` is a separate data URI,
|
||||
* whereas the `url` is the original URL of the manifest.
|
||||
*
|
||||
* This allows us to intercept requests for the original manifest file, but serve our
|
||||
* updated version of the manifest.
|
||||
*/
|
||||
const manifestTuple = [selectedSource.src, offlineManifestDataURI];
|
||||
|
||||
URLTuples = parser.listAllChunkURLs([manifestTuple]);
|
||||
} else {
|
||||
return [];
|
||||
urls.push(selectedSource.src);
|
||||
urls = [...parser.listAllChunkURLs(), ...urls];
|
||||
} catch (e) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error('Error fetching and parsing the offline MPD manifest.', e);
|
||||
}
|
||||
} else {
|
||||
URLTuples = [[selectedSource.src, selectedSource.src]];
|
||||
urls.push(selectedSource.src);
|
||||
}
|
||||
|
||||
const fileMeta = URLTuples.map(
|
||||
([url, downloadUrl]) => ({
|
||||
url,
|
||||
downloadUrl,
|
||||
videoId,
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: null,
|
||||
done: false,
|
||||
}),
|
||||
);
|
||||
|
||||
return fileMeta;
|
||||
return urls;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Copyright 2021 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import api from '../../../public/api.json';
|
||||
|
||||
/**
|
||||
* Returns a list of videos sources associated with a video.
|
||||
*
|
||||
* @param {string} videoId Video ID.
|
||||
* @returns {videoSource[]} Video source objects.
|
||||
*/
|
||||
export default function getVideoSources(videoId) {
|
||||
const foundVideo = api.videos.find((video) => video.id === videoId);
|
||||
|
||||
if (foundVideo) {
|
||||
return foundVideo['video-sources'];
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Copyright 2021 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '../typedefs';
|
||||
import api from '../../../public/api.json';
|
||||
|
||||
/**
|
||||
* Rewrite a source URL based on from and to parameters.
|
||||
*
|
||||
* Useful for rewriting manifest file URLs, because the offline versions
|
||||
* of manifests only contain a single video representation.
|
||||
*
|
||||
* @param {string} videoId Video ID.
|
||||
* @param {string} sourceUrl Source URL to find offline alternative for.
|
||||
* @param {string} from From which key.
|
||||
* @param {string} to To which key.
|
||||
* @returns {string} A rewritten URL.
|
||||
*/
|
||||
export default function rewriteURL(videoId, sourceUrl, from, to) {
|
||||
const videoData = api.videos.find((video) => video.id === videoId);
|
||||
|
||||
if (!videoData) {
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
if (videoData['url-rewrites']) {
|
||||
videoData['url-rewrites'].forEach((rewrite) => {
|
||||
if (rewrite[from] === sourceUrl) {
|
||||
sourceUrl = rewrite[to];
|
||||
}
|
||||
});
|
||||
}
|
||||
return sourceUrl;
|
||||
}
|
|
@ -14,13 +14,72 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import getRepresentationMimeString from './getRepresentationMimeString';
|
||||
|
||||
import {
|
||||
DEFAULT_AUDIO_PRIORITIES,
|
||||
DEFAULT_VIDEO_PRIORITIES,
|
||||
ALL_STREAM_TYPES,
|
||||
} from '../constants';
|
||||
import getDecodingInfo from './getDecodingInfo';
|
||||
import { getMediaConfigurationAudio, getMediaConfigurationVideo } from './getMediaConfiguration';
|
||||
|
||||
/**
|
||||
* Uses the Media Capabilities API to filter out media representations
|
||||
* that don't match the provided set of requirements.
|
||||
*
|
||||
* @param {Array} representations Media representations.
|
||||
* @param {'audio'|'video'} contentType Media type.
|
||||
* @param {Array} requirements Array of requirements for Media Capabilities API.
|
||||
* @returns {Promise<Array>} Set of representations matching the requirements.
|
||||
*/
|
||||
const selectRepresentationsByRequirements = async (
|
||||
representations,
|
||||
contentType,
|
||||
requirements = [
|
||||
{
|
||||
supported: true,
|
||||
smooth: true,
|
||||
powerEfficient: true,
|
||||
},
|
||||
{
|
||||
supported: true,
|
||||
smooth: true,
|
||||
},
|
||||
{
|
||||
supported: true,
|
||||
},
|
||||
],
|
||||
) => {
|
||||
// Strips away the first item from `requirements`, this allows us to call
|
||||
// this method recursively later.
|
||||
const currentRequirements = requirements.shift();
|
||||
const matchedRepresentations = [];
|
||||
|
||||
for (const currentRepresentation of representations) {
|
||||
const mediaConfiguration = contentType === 'audio'
|
||||
? getMediaConfigurationAudio(currentRepresentation)
|
||||
: getMediaConfigurationVideo(currentRepresentation);
|
||||
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
const decodingInfo = await getDecodingInfo(mediaConfiguration);
|
||||
|
||||
let matchesRequirements = true;
|
||||
Object.entries(currentRequirements).forEach(([key, value]) => {
|
||||
matchesRequirements = matchesRequirements && (decodingInfo[key] === value);
|
||||
});
|
||||
|
||||
if (matchesRequirements) {
|
||||
matchedRepresentations.push(currentRepresentation);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedRepresentations.length > 0) {
|
||||
return matchedRepresentations;
|
||||
}
|
||||
|
||||
return requirements.length > 0
|
||||
? selectRepresentationsByRequirements(representations, contentType, requirements)
|
||||
: [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all representations present in the MPD file and filter
|
||||
|
@ -28,23 +87,14 @@ import {
|
|||
*
|
||||
* @param {object} parser MPD parser instance.
|
||||
* @param {object} opts Options.
|
||||
* @returns {object[]} All representations that the current client is able to play.
|
||||
* @returns {Promise<object[]>} All representations that the current client is able to play.
|
||||
*/
|
||||
export default (parser, opts = {}) => {
|
||||
const videoEl = document.createElement('video');
|
||||
/**
|
||||
* Returns whether the provided representation is playable by the current client.
|
||||
*
|
||||
* @param {object} representation Representation object returned by parser.queryRepresentations.
|
||||
* @returns {boolean} Is this representation playable by the current client.
|
||||
*/
|
||||
const canPlayFilter = (representation) => {
|
||||
const testMime = getRepresentationMimeString(representation);
|
||||
return videoEl.canPlayType(testMime) === 'probably';
|
||||
export default async (parser, opts = {}) => {
|
||||
const representations = {
|
||||
video: [],
|
||||
audio: [],
|
||||
};
|
||||
|
||||
const representations = {};
|
||||
|
||||
const priorities = {
|
||||
video: [...(opts.videoPriorities || DEFAULT_VIDEO_PRIORITIES)],
|
||||
audio: [...(opts.audioPriorities || DEFAULT_AUDIO_PRIORITIES)],
|
||||
|
@ -54,17 +104,20 @@ export default (parser, opts = {}) => {
|
|||
* Select sets of video and audio representations that are playable
|
||||
* in the client with respect to indicated priorities.
|
||||
*/
|
||||
ALL_STREAM_TYPES.forEach(
|
||||
(contentType) => {
|
||||
let query;
|
||||
do {
|
||||
query = priorities[contentType].shift() || '';
|
||||
for (const contentType of ALL_STREAM_TYPES) {
|
||||
let query;
|
||||
|
||||
representations[contentType] = parser.queryRepresentations(query, contentType);
|
||||
representations[contentType] = representations[contentType].filter(canPlayFilter);
|
||||
} while (representations[contentType].length === 0 && query);
|
||||
},
|
||||
);
|
||||
do {
|
||||
query = priorities[contentType].shift() || '';
|
||||
const candidates = parser.queryRepresentations(query, contentType);
|
||||
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
representations[contentType] = await selectRepresentationsByRequirements(
|
||||
candidates,
|
||||
contentType,
|
||||
);
|
||||
} while (representations[contentType].length === 0 && query);
|
||||
}
|
||||
|
||||
if (representations.video.length === 0) {
|
||||
throw new Error('[Streamer] No playable video representation found.');
|
||||
|
|
|
@ -164,6 +164,15 @@
|
|||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
/* Removes button interactivity when controls are disabled */
|
||||
:host( [state="partial"][downloading="true"] ) button.downloading {
|
||||
pointer-events: none;
|
||||
}
|
||||
:host( [state="partial"][downloading="true"][nocontrols="true"] ) button.downloading svg path[fill] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Progress icon (paused state) */
|
||||
:host( [state="partial"][downloading="false"] ) button.paused {
|
||||
display: flex;
|
||||
|
|
|
@ -19,11 +19,17 @@ import styles from './VideoDownloader.css';
|
|||
import getIDBConnection from '../../classes/IDBConnection';
|
||||
import DownloadManager from '../../classes/DownloadManager';
|
||||
import StorageManager from '../../classes/StorageManager';
|
||||
import getURLsForDownload from '../../utils/getURLsForDownload';
|
||||
import { MEDIA_SESSION_DEFAULT_ARTWORK, SETTING_KEY_BG_FETCH_API } from '../../constants';
|
||||
import BackgroundFetch from '../../classes/BackgroundFetch';
|
||||
import { loadSetting } from '../../utils/settings';
|
||||
import getProgress from '../../utils/getProgress';
|
||||
|
||||
export default class VideoDownloader extends HTMLElement {
|
||||
/**
|
||||
* @type {string[]}
|
||||
*/
|
||||
static get observedAttributes() {
|
||||
return ['state', 'progress', 'downloading', 'willremove'];
|
||||
return ['state', 'progress', 'downloading', 'willremove', 'nocontrols'];
|
||||
}
|
||||
|
||||
constructor({ connectionStatus }) {
|
||||
|
@ -33,7 +39,14 @@ export default class VideoDownloader extends HTMLElement {
|
|||
connectionStatus,
|
||||
changeCallbacks: [],
|
||||
root: this.attachShadow({ mode: 'open' }),
|
||||
files: [],
|
||||
};
|
||||
|
||||
/** @type {DownloadManager} */
|
||||
this.downloadManager = null;
|
||||
|
||||
/** @type {StorageManager} */
|
||||
this.storageManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -53,12 +66,7 @@ export default class VideoDownloader extends HTMLElement {
|
|||
}
|
||||
|
||||
set state(state) {
|
||||
const oldState = this.state;
|
||||
this.setAttribute('state', state);
|
||||
|
||||
this.internal.changeCallbacks.forEach(
|
||||
(callback) => callback(oldState, state),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -100,6 +108,14 @@ export default class VideoDownloader extends HTMLElement {
|
|||
this.setAttribute('willremove', willremove);
|
||||
}
|
||||
|
||||
get nocontrols() {
|
||||
return this.getAttribute('nocontrols') === 'true';
|
||||
}
|
||||
|
||||
set nocontrols(nocontrols) {
|
||||
this.setAttribute('nocontrols', nocontrols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observed attributes callbacks.
|
||||
*
|
||||
|
@ -112,6 +128,25 @@ export default class VideoDownloader extends HTMLElement {
|
|||
const percentageAsDashOffset = 82 - (82 * value);
|
||||
this.internal.root.host.style.setProperty('--progress', percentageAsDashOffset);
|
||||
}
|
||||
|
||||
// Broadcast changes in several internal properties.
|
||||
const currentState = {
|
||||
state: this.state,
|
||||
willremove: this.willremove,
|
||||
nocontrols: this.nocontrols,
|
||||
};
|
||||
|
||||
if (Object.keys(currentState).includes(name)) {
|
||||
const typecastBooleans = (val) => (['false', 'true'].includes(val) ? val === 'true' : val);
|
||||
const oldState = {
|
||||
...currentState,
|
||||
[name]: typecastBooleans(old),
|
||||
};
|
||||
|
||||
this.internal.changeCallbacks.forEach(
|
||||
(callback) => callback(oldState, currentState),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -120,7 +155,7 @@ export default class VideoDownloader extends HTMLElement {
|
|||
* @param {object} videoData Video data coming from the API.
|
||||
* @param {string} cacheName Cache name.
|
||||
*/
|
||||
init(videoData, cacheName = 'v1') {
|
||||
async init(videoData, cacheName = 'v1') {
|
||||
this.internal = {
|
||||
...this.internal,
|
||||
videoData,
|
||||
|
@ -129,28 +164,18 @@ export default class VideoDownloader extends HTMLElement {
|
|||
};
|
||||
|
||||
const videoId = this.getId();
|
||||
const sources = this.internal.videoData['video-sources'] || [];
|
||||
const db = await getIDBConnection();
|
||||
const videoMeta = await db.meta.get(videoId);
|
||||
|
||||
getURLsForDownload(videoId, sources).then(async (files) => {
|
||||
const db = await getIDBConnection();
|
||||
const dbFiles = await db.file.getByVideoId(videoId);
|
||||
const dbFilesUrlTuples = dbFiles.map((fileMeta) => [fileMeta.url, fileMeta]);
|
||||
const dbFilesByUrl = Object.fromEntries(dbFilesUrlTuples);
|
||||
this.downloadManager = new DownloadManager(this.getId());
|
||||
this.internal.files = await this.downloadManager.prepareFileMeta();
|
||||
|
||||
/**
|
||||
* If we have an entry for this file in the database, use it. Otherwise
|
||||
* fall back to the freshly generated FileMeta object.
|
||||
*/
|
||||
const filesWithStateUpdatedFromDb = files.map(
|
||||
(fileMeta) => (dbFilesByUrl[fileMeta.url] ? dbFilesByUrl[fileMeta.url] : fileMeta),
|
||||
);
|
||||
|
||||
const videoMeta = await db.meta.get(videoId);
|
||||
this.setMeta(videoMeta);
|
||||
this.internal.files = filesWithStateUpdatedFromDb;
|
||||
|
||||
this.render();
|
||||
this.storageManager = new StorageManager(this.getId(), {
|
||||
fileMeta: this.internal.files,
|
||||
});
|
||||
|
||||
this.setMeta(videoMeta);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -180,6 +205,19 @@ export default class VideoDownloader extends HTMLElement {
|
|||
return subtitlesUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the artwork images URLs used by Media Session API
|
||||
* to render the media popup / notification.
|
||||
*
|
||||
* @returns {string[]} URLs.
|
||||
*/
|
||||
getMediaSessionArtworkUrls() {
|
||||
const artworkObjects = this.internal.videoData['media-session-artwork'] || MEDIA_SESSION_DEFAULT_ARTWORK;
|
||||
const artworkUrls = artworkObjects.map((artworkObject) => artworkObject.src);
|
||||
|
||||
return artworkUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves assets to the specified cache using Cache API.
|
||||
*
|
||||
|
@ -188,7 +226,12 @@ export default class VideoDownloader extends HTMLElement {
|
|||
async saveToCache(urls) {
|
||||
try {
|
||||
const cache = await caches.open(this.internal.cacheName);
|
||||
await cache.addAll(urls);
|
||||
|
||||
urls.forEach(async (url) => {
|
||||
if (!await cache.match(url)) {
|
||||
await cache.add(url);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
/**
|
||||
|
@ -203,53 +246,59 @@ export default class VideoDownloader extends HTMLElement {
|
|||
|
||||
/**
|
||||
* Downloads the current video and its assets to the cache and IDB.
|
||||
*
|
||||
* @param {object} opts Download options.
|
||||
* @param {boolean} opts.assetsOnly Whether to cache only video assets: poster images,
|
||||
* subtitles and Media Session API artowrk.
|
||||
*/
|
||||
async download() {
|
||||
async download(opts = {}) {
|
||||
const posterURLs = this.getPosterURLs();
|
||||
const subtitlesURLs = this.getSubtitlesUrls();
|
||||
const mediaSessionArtworkURLs = this.getMediaSessionArtworkUrls();
|
||||
|
||||
this.downloading = true;
|
||||
this.saveToCache([...posterURLs, ...subtitlesURLs]);
|
||||
this.runIDBDownloads();
|
||||
this.saveToCache([...posterURLs, ...subtitlesURLs, ...mediaSessionArtworkURLs]);
|
||||
|
||||
if (!opts.assetsOnly) {
|
||||
this.downloading = true;
|
||||
this.state = 'partial';
|
||||
|
||||
if (
|
||||
loadSetting(SETTING_KEY_BG_FETCH_API)
|
||||
&& 'BackgroundFetchManager' in window
|
||||
&& 'serviceWorker' in navigator
|
||||
) {
|
||||
this.nocontrols = true; // Browser will handle the download UI.
|
||||
this.downloadUsingBackgroundFetch();
|
||||
} else {
|
||||
this.downloadSynchronously();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total download progress for the video.
|
||||
*
|
||||
* @returns {number} Percentage progress for the video in the range 0–100.
|
||||
*/
|
||||
getProgress() {
|
||||
const pieceValue = 1 / this.internal.files.length;
|
||||
const percentageProgress = this.internal.files.reduce(
|
||||
(percentage, fileMeta) => {
|
||||
if (fileMeta.done) {
|
||||
percentage += pieceValue;
|
||||
} else if (fileMeta.bytesDownloaded === 0 || !fileMeta.bytesTotal) {
|
||||
percentage += 0;
|
||||
} else {
|
||||
const percentageOfCurrent = fileMeta.bytesDownloaded / fileMeta.bytesTotal;
|
||||
percentage += percentageOfCurrent * pieceValue;
|
||||
}
|
||||
return percentage;
|
||||
},
|
||||
0,
|
||||
);
|
||||
const clampedPercents = Math.max(0, Math.min(percentageProgress, 1));
|
||||
downloadUsingBackgroundFetch() {
|
||||
const bgFetch = new BackgroundFetch();
|
||||
|
||||
return clampedPercents;
|
||||
bgFetch.onprogress = (progress) => {
|
||||
this.progress = progress;
|
||||
};
|
||||
bgFetch.ondone = () => {
|
||||
this.progress = 100;
|
||||
this.downloading = false;
|
||||
this.state = 'done';
|
||||
};
|
||||
|
||||
bgFetch.start(this.internal.videoData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a list of video URLs, downloads the video using a stream reader
|
||||
* and invokes `storeVideoChunk` to store individual video chunks in IndexedDB.
|
||||
*/
|
||||
async runIDBDownloads() {
|
||||
this.downloadManager = new DownloadManager(this);
|
||||
this.storageManager = new StorageManager(this);
|
||||
|
||||
async downloadSynchronously() {
|
||||
this.storageManager.onprogress = (progress) => {
|
||||
this.progress = progress;
|
||||
};
|
||||
|
||||
this.storageManager.onerror = (error) => {
|
||||
if (this.downloading && error.name === 'QuotaExceededError') {
|
||||
/**
|
||||
|
@ -277,9 +326,8 @@ export default class VideoDownloader extends HTMLElement {
|
|||
* to make sure all chunks are sent to the `storeChunk` method of the `StoreManager`.
|
||||
*/
|
||||
const boundStoreChunkHandler = this.storageManager.storeChunk.bind(this.storageManager);
|
||||
this.downloadManager.onflush = boundStoreChunkHandler;
|
||||
this.downloadManager.attachFlushHandler(boundStoreChunkHandler);
|
||||
|
||||
this.state = 'partial';
|
||||
this.downloadManager.run();
|
||||
}
|
||||
|
||||
|
@ -377,14 +425,12 @@ export default class VideoDownloader extends HTMLElement {
|
|||
await this.removeFromIDB();
|
||||
window.removeEventListener('beforeunload', this.unloadHandler);
|
||||
}, 5000);
|
||||
} else if (e.target.classList.contains('action--undo')) {
|
||||
if (this.willremove === true) {
|
||||
if (this.removalTimeout) {
|
||||
this.state = 'done';
|
||||
this.willremove = false;
|
||||
clearTimeout(this.removalTimeout);
|
||||
window.removeEventListener('beforeunload', this.unloadHandler);
|
||||
}
|
||||
} else if (this.willremove === true) {
|
||||
if (this.removalTimeout) {
|
||||
this.state = 'done';
|
||||
this.willremove = false;
|
||||
clearTimeout(this.removalTimeout);
|
||||
window.removeEventListener('beforeunload', this.unloadHandler);
|
||||
}
|
||||
} else if (e.target.classList.contains('action--cancel')) {
|
||||
this.removeFromIDB();
|
||||
|
@ -445,7 +491,7 @@ export default class VideoDownloader extends HTMLElement {
|
|||
*/
|
||||
async setDownloadState() {
|
||||
const videoMeta = this.getMeta();
|
||||
const downloadProgress = this.getProgress();
|
||||
const downloadProgress = getProgress(this.internal.files);
|
||||
|
||||
if (videoMeta.done) {
|
||||
this.state = 'done';
|
||||
|
@ -455,6 +501,7 @@ export default class VideoDownloader extends HTMLElement {
|
|||
} else {
|
||||
this.state = 'ready';
|
||||
}
|
||||
|
||||
this.downloading = false;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host *:focus {
|
||||
|
@ -9,7 +10,122 @@
|
|||
outline-color: var(--accent);
|
||||
}
|
||||
|
||||
:host .floating-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
column-gap: 16px;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: auto; /* In order to not interfere with the mobile slide out menu. */
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
:host .floating-buttons {
|
||||
top: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
:host .floating-buttons > * {
|
||||
border-radius: 8px;
|
||||
background-color: var(--accent-background);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:host .floating-buttons > button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button google-cast-launcher {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
--connected-color: var(--accent);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.pip-overlay,
|
||||
.cast-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--code-background);
|
||||
color: var(--icon);
|
||||
z-index: 1;
|
||||
font-size: clamp(12px, 4vw, 24px);
|
||||
}
|
||||
|
||||
.pip-overlay svg,
|
||||
.cast-overlay svg {
|
||||
align-self: end;
|
||||
width: clamp(40px, 20vw, 128px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:host(.picture-in-picture) .pip-overlay,
|
||||
:host(.cast) .cast-overlay {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.cast-overlay .cast-target {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host(.cast-has-target) .cast-overlay .cast-target {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.stats-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
max-height: 65%;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
background-color: var(--code-background);
|
||||
color: var(--icon);
|
||||
padding: 1em;
|
||||
opacity: 0.9;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stats-overlay h4 {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.stats-overlay h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:host(.stats-overlay-visible) .stats-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
:host(.picture-in-picture) .pip-overlay {
|
||||
row-gap: 32px;
|
||||
}
|
||||
:host(.cast) .cast-overlay {
|
||||
row-gap: 32px;
|
||||
}
|
||||
.stats-overlay {
|
||||
inset: 1em;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,14 +19,30 @@ import Streamer from '../../classes/Streamer';
|
|||
import ParserMPD from '../../classes/ParserMPD';
|
||||
import selectSource from '../../utils/selectSource';
|
||||
|
||||
import { MEDIA_SESSION_DEFAULT_ARTWORK } from '../../constants';
|
||||
import {
|
||||
CAST_CLASSNAME,
|
||||
CAST_HAS_TARGET_NAME,
|
||||
CAST_TARGET_NAME,
|
||||
MEDIA_SESSION_DEFAULT_ARTWORK,
|
||||
PIP_CLASSNAME,
|
||||
STATS_OVERLAY_CLASSNAME,
|
||||
STATS_OVERLAY_DISPLAYED_CLASSNAME,
|
||||
} from '../../constants';
|
||||
import decryptVideo from '../../utils/decryptVideo';
|
||||
import { getMediaConfigurationAudio, getMediaConfigurationVideo } from '../../utils/getMediaConfiguration';
|
||||
import getDecodingInfo from '../../utils/getDecodingInfo';
|
||||
|
||||
export default class extends HTMLElement {
|
||||
constructor() {
|
||||
export default class VideoPlayer extends HTMLElement {
|
||||
/**
|
||||
* @param {VideoDownloader} downloader Video downloader associated with the current video.
|
||||
*/
|
||||
constructor(downloader) {
|
||||
super();
|
||||
|
||||
this.internal = {};
|
||||
this.internal.root = this.attachShadow({ mode: 'open' });
|
||||
this.internal = {
|
||||
downloader,
|
||||
root: this.attachShadow({ mode: 'open' }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -61,6 +77,20 @@ export default class extends HTMLElement {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the video.
|
||||
*/
|
||||
mute() {
|
||||
this.videoElement.muted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmutes the video.
|
||||
*/
|
||||
unmute() {
|
||||
this.videoElement.muted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
|
@ -77,10 +107,23 @@ export default class extends HTMLElement {
|
|||
: videoData.thumbnail;
|
||||
|
||||
const markup = `<style>${styles}</style>
|
||||
<video ${videoData.thumbnail ? `poster="${thumbnailUrl}"` : ''} controls crossorigin="anonymous">
|
||||
<video
|
||||
${videoData.thumbnail ? `poster="${thumbnailUrl}"` : ''}
|
||||
controls crossorigin="anonymous"
|
||||
>
|
||||
${this.getSourceHTML()}
|
||||
${this.getTracksHTML()}
|
||||
</video>
|
||||
<div class="floating-buttons"></div>
|
||||
<div class="pip-overlay">
|
||||
<svg viewBox="0 0 129 128" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M108.5 48V16a8.001 8.001 0 0 0-8-8h-84a8 8 0 0 0-8 8v68a8 8 0 0 0 8 8h20" stroke="var(--icon)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M52.5 112V72a8 8 0 0 1 8-8h52a8 8 0 0 1 8 8v40a8 8 0 0 1-8 8h-52a8 8 0 0 1-8-8Z" stroke="var(--icon)" stroke-width="3" stroke-miterlimit="10" stroke-linecap="square"/></svg>
|
||||
<p>This video is playing in picture in picture</p>
|
||||
</div>
|
||||
<div class="cast-overlay">
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" clip-rule="evenodd" stroke-linecap="round" stroke-miterlimit="10"><path d="M34.133 107.201c0-13.253-10.747-24-24-24M53.333 107.2c0-23.861-19.339-43.2-43.2-43.2" fill="none" stroke="var(--icon)" stroke-width="8"/><path d="M10.133 112.001a4.8 4.8 0 1 0 0-9.6 4.8 4.8 0 0 0 0 9.6Z" fill="var(--icon)" fill-rule="nonzero"/><path d="M5.333 49.778V32c0-5.891 4.776-10.667 10.667-10.667h96c5.891 0 10.667 4.776 10.667 10.667v64c0 5.891-4.776 10.667-10.667 10.667H72.381" fill="none" stroke="var(--icon)" stroke-width="8"/></svg>
|
||||
<p>Casting<span class="cast-target"> to <span class="cast-target-name"></span></span></p>
|
||||
</div>
|
||||
<div class="stats-overlay">STATS OVERLAY</div>
|
||||
`;
|
||||
|
||||
while (this.internal.root.firstChild) {
|
||||
|
@ -88,9 +131,34 @@ export default class extends HTMLElement {
|
|||
}
|
||||
this.internal.root.innerHTML = markup;
|
||||
|
||||
/**
|
||||
* @type {HTMLMediaElement}
|
||||
*/
|
||||
this.videoElement = this.internal.root.querySelector('video');
|
||||
this.videoElement.addEventListener('error', this.handleVideoError.bind(this), true);
|
||||
|
||||
if (videoData.encryption) {
|
||||
decryptVideo(this.videoElement, videoData.encryption);
|
||||
}
|
||||
|
||||
const floatingButtonsBar = this.internal.root.querySelector('.floating-buttons');
|
||||
const pipButton = this.createPiPButton();
|
||||
|
||||
if (pipButton) {
|
||||
floatingButtonsBar.appendChild(pipButton);
|
||||
}
|
||||
|
||||
if (this.internal.videoData.cast) {
|
||||
window.kinoInitGoogleCast().then((castButton) => {
|
||||
floatingButtonsBar.appendChild(castButton);
|
||||
|
||||
window.cast.framework.CastContext.getInstance().addEventListener(
|
||||
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
this.initCast.bind(this),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Media Session API integration.
|
||||
*/
|
||||
|
@ -98,6 +166,20 @@ export default class extends HTMLElement {
|
|||
this.videoElement.addEventListener('play', () => {
|
||||
if (!this.internal.mediaSessionIsInit) this.initMediaSession();
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up the stats overlay.
|
||||
*/
|
||||
if (this.internal.videoData.stats) {
|
||||
this.videoElement.addEventListener(
|
||||
'play',
|
||||
() => this.classList.add(STATS_OVERLAY_DISPLAYED_CLASSNAME),
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
this.videoElement.addEventListener('playing', this.updateStatsOverlay.bind(this));
|
||||
this.videoElement.addEventListener('timeupdate', this.updateStatsOverlay.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,7 +241,7 @@ export default class extends HTMLElement {
|
|||
title: this.internal.videoData.title || '',
|
||||
artist: this.internal.videoData.artist || '',
|
||||
album: this.internal.videoData.album || '',
|
||||
artwork: MEDIA_SESSION_DEFAULT_ARTWORK,
|
||||
artwork: this.internal.videoData['media-session-artwork'] || MEDIA_SESSION_DEFAULT_ARTWORK,
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -280,13 +362,238 @@ export default class extends HTMLElement {
|
|||
* indicates no data has been fetched at all. In those cases
|
||||
* it's possible we're initializing MSE and we can't really
|
||||
* use the `play` method if the source is going to change.
|
||||
*
|
||||
* @returns {Promise<this>} Promise indicating whether the playback started.
|
||||
* Returns the current `VideoPlayer` instance, which
|
||||
* allows outside code to respond to success or failure
|
||||
* adequately, e.g. by muting the video and retrying.
|
||||
*/
|
||||
play() {
|
||||
const HAVE_NOTHING = 0;
|
||||
if (this.videoElement.readyState === HAVE_NOTHING) {
|
||||
this.videoElement.addEventListener('loadeddata', this.videoElement.play, { once: true });
|
||||
} else {
|
||||
this.videoElement.play();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const resolvePlayIntent = async () => {
|
||||
try {
|
||||
await this.videoElement.play();
|
||||
|
||||
this.internal.downloader.download({
|
||||
assetsOnly: true,
|
||||
});
|
||||
|
||||
resolve(this);
|
||||
} catch (_) {
|
||||
reject(this);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.videoElement.readyState === HAVE_NOTHING) {
|
||||
this.videoElement.addEventListener('loadeddata', resolvePlayIntent, { once: true });
|
||||
} else {
|
||||
resolvePlayIntent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a button that controls the PiP functionality.
|
||||
*
|
||||
* @returns {HTMLButtonElement|null} Button element or null when PiP not supported.
|
||||
*/
|
||||
createPiPButton() {
|
||||
if (!('pictureInPictureEnabled' in document)) {
|
||||
return null;
|
||||
}
|
||||
if (!this.internal.videoData.pip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ENTER_PIP_SVG = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.25 15v6a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 21V8.25A1.5 1.5 0 0 1 3 6.75h3.75" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.75 3v7.5a1.5 1.5 0 0 0 1.5 1.5H21a1.5 1.5 0 0 0 1.5-1.5V3A1.5 1.5 0 0 0 21 1.5h-9.75A1.5 1.5 0 0 0 9.75 3Z" stroke="var(--accent)" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="square"/><path d="M9 18.75V15H5.25M5.25 18.75 9 15" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const LEAVE_PIP_SVG = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.25 9V3a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 3v12.75a1.5 1.5 0 0 0 1.5 1.5h3.75" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.25 9V5.25H9M9 9 5.25 5.25" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.75 21v-7.5a1.5 1.5 0 0 1 1.5-1.5H21a1.5 1.5 0 0 1 1.5 1.5V21a1.5 1.5 0 0 1-1.5 1.5h-9.75a1.5 1.5 0 0 1-1.5-1.5Z" stroke="var(--accent)" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="square"/></svg>';
|
||||
|
||||
const pipButton = document.createElement('button');
|
||||
const setPipButton = () => {
|
||||
pipButton.disabled = (this.videoElement.readyState === 0)
|
||||
|| !document.pictureInPictureEnabled
|
||||
|| this.videoElement.disablePictureInPicture;
|
||||
};
|
||||
|
||||
pipButton.setAttribute('aria-label', 'Toggle picture in picture');
|
||||
pipButton.innerHTML = ENTER_PIP_SVG;
|
||||
|
||||
pipButton.addEventListener('click', async () => {
|
||||
pipButton.disabled = true;
|
||||
try {
|
||||
if (this !== document.pictureInPictureElement) {
|
||||
// If another video is already in PiP, pause it.
|
||||
if (document.pictureInPictureElement instanceof VideoPlayer) {
|
||||
document.pictureInPictureElement.videoElement.pause();
|
||||
}
|
||||
await this.videoElement.requestPictureInPicture();
|
||||
} else {
|
||||
await document.exitPictureInPicture();
|
||||
}
|
||||
} catch (error) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(error);
|
||||
} finally {
|
||||
pipButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.videoElement.addEventListener('loadedmetadata', setPipButton);
|
||||
this.videoElement.addEventListener('emptied', setPipButton);
|
||||
this.videoElement.addEventListener('enterpictureinpicture', () => {
|
||||
pipButton.innerHTML = LEAVE_PIP_SVG;
|
||||
this.classList.add(PIP_CLASSNAME);
|
||||
});
|
||||
this.videoElement.addEventListener('leavepictureinpicture', () => {
|
||||
pipButton.innerHTML = ENTER_PIP_SVG;
|
||||
this.classList.remove(PIP_CLASSNAME);
|
||||
});
|
||||
|
||||
setPipButton();
|
||||
|
||||
return pipButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes Google Cast functionality.
|
||||
*
|
||||
* @param {SessionStateEventData} e SessionStateEventData instance.
|
||||
* @returns {void}
|
||||
*/
|
||||
async initCast(e) {
|
||||
if (e.sessionState === 'SESSION_STARTED' || e.sessionState === 'SESSION_RESUMED') {
|
||||
const castableSources = this.internal.videoData['video-sources'].filter((source) => source.cast === true);
|
||||
|
||||
if (!castableSources) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error('[Google Cast] The media has no source suitable for casting.');
|
||||
return;
|
||||
}
|
||||
|
||||
const castSession = window.cast.framework.CastContext.getInstance().getCurrentSession();
|
||||
const mediaInfo = new window.chrome.cast.media.MediaInfo(
|
||||
castableSources[0].src,
|
||||
castableSources[0].type,
|
||||
);
|
||||
const videoThumbnail = new window.chrome.cast.Image(this.internal.videoData.thumbnail);
|
||||
const metadata = new window.chrome.cast.media.GenericMediaMetadata();
|
||||
|
||||
metadata.title = this.internal.videoData.title;
|
||||
|
||||
/**
|
||||
* @todo Add the Media Session artwork and define image dimensions explicitly.
|
||||
*/
|
||||
metadata.images = [videoThumbnail];
|
||||
mediaInfo.metadata = metadata;
|
||||
|
||||
/** @type {Array} */
|
||||
const subtitles = this.internal.videoData['video-subtitles'] || [];
|
||||
const defaultSubtitles = subtitles.find((subtitle) => subtitle.default);
|
||||
|
||||
/**
|
||||
* AFAICT the Default Media Receiver doesn't implement any UI to
|
||||
* select the subtitle track.
|
||||
*
|
||||
* We only add the subtitle track if there is a default one.
|
||||
*/
|
||||
if (defaultSubtitles) {
|
||||
const defaultSubtitlesTrack = new window.chrome.cast.media.Track(
|
||||
1,
|
||||
window.chrome.cast.media.TrackType.TEXT,
|
||||
);
|
||||
|
||||
defaultSubtitlesTrack.trackContentId = defaultSubtitles.src;
|
||||
defaultSubtitlesTrack.subtype = window.chrome.cast.media.TextTrackType.SUBTITLES;
|
||||
defaultSubtitlesTrack.name = defaultSubtitles.label;
|
||||
defaultSubtitlesTrack.language = defaultSubtitles.srclang;
|
||||
defaultSubtitlesTrack.trackContentType = 'text/vtt';
|
||||
|
||||
mediaInfo.tracks = [defaultSubtitlesTrack];
|
||||
}
|
||||
|
||||
const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
|
||||
|
||||
try {
|
||||
await castSession.loadMedia(request);
|
||||
} catch (error) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(`[Google Cast] Error code: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetName = castSession.getCastDevice().friendlyName;
|
||||
this.internal.root.querySelector(`.${CAST_TARGET_NAME}`).innerText = targetName;
|
||||
this.classList.toggle(CAST_HAS_TARGET_NAME, targetName);
|
||||
this.classList.add(CAST_CLASSNAME);
|
||||
}
|
||||
|
||||
if (e.sessionState === 'SESSION_ENDED') {
|
||||
this.classList.remove(CAST_CLASSNAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the video playback statistics rendered on top of the video.
|
||||
*/
|
||||
async updateStatsOverlay() {
|
||||
let capText = '';
|
||||
let vqText = '';
|
||||
let mediaText = '';
|
||||
|
||||
const statsOverlayEl = this.internal.root.querySelector(`.${STATS_OVERLAY_CLASSNAME}`);
|
||||
|
||||
/** @type {VideoPlaybackQuality} */
|
||||
const vq = this.videoElement.getVideoPlaybackQuality();
|
||||
const vqData = [
|
||||
['Total frames: ', vq.totalVideoFrames],
|
||||
['Dropped frames: ', vq.droppedVideoFrames],
|
||||
];
|
||||
vqText = vqData.map(([label, value]) => `<div>${label}${value}</div>`).join('');
|
||||
|
||||
const reps = this.internal.streamer?.stream?.media?.representations;
|
||||
const selectedReps = this.internal.streamer?.stream?.media?.lastRepresentationsIds;
|
||||
|
||||
if (reps && selectedReps) {
|
||||
const videoId = selectedReps.video;
|
||||
const audioId = selectedReps.audio;
|
||||
|
||||
const videoRep = reps.video.find((rep) => rep.id === videoId);
|
||||
const audioRep = reps.audio.find((rep) => rep.id === audioId);
|
||||
|
||||
if (videoRep && audioRep) {
|
||||
const videoConfiguration = getMediaConfigurationVideo(videoRep);
|
||||
const audioConfiguration = getMediaConfigurationAudio(audioRep);
|
||||
const mediaConfiguration = { ...videoConfiguration, ...audioConfiguration };
|
||||
|
||||
const decodingInfo = await getDecodingInfo(mediaConfiguration);
|
||||
|
||||
const capData = [
|
||||
['Power efficient: ', decodingInfo.powerEfficient],
|
||||
['Smooth: ', decodingInfo.smooth],
|
||||
['Supported: ', decodingInfo.supported],
|
||||
];
|
||||
capText = capData.map(([label, value]) => `<div>${label}${value}</div>`).join('');
|
||||
|
||||
const mediaData = [
|
||||
['Video codec: ', videoConfiguration.video.contentType],
|
||||
['Video resolution: ', `${videoConfiguration.video.width}x${videoConfiguration.video.height}`],
|
||||
['Audio codec: ', audioConfiguration.audio.contentType],
|
||||
['Audio bitrate: ', audioConfiguration.audio.bitrate],
|
||||
['Audio sampling rate: ', audioConfiguration.audio.samplerate],
|
||||
['Audio channels: ', audioConfiguration.audio.channels],
|
||||
];
|
||||
mediaText = mediaData.map(([label, value]) => `<div>${label}${value}</div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
statsOverlayEl.innerHTML = `
|
||||
<h4>Media Info</h4>
|
||||
${mediaText}
|
||||
<h4>Video Playback Quality API</h4>
|
||||
${vqText}
|
||||
<h4>Media Capabilities API</h4>
|
||||
${capText}`;
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче