Merge branch 'add/cast' of github.com:GoogleChrome/kino into add/cast

This commit is contained in:
Jaroslav Polakovič 2022-01-17 15:38:06 +01:00
Родитель 1596a09842 4d7a7040ff
Коммит 50cf313546
38 изменённых файлов: 1342 добавлений и 409 удалений

Просмотреть файл

@ -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",

47
package-lock.json сгенерированный
Просмотреть файл

@ -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": {

Просмотреть файл

@ -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;
@ -623,57 +625,68 @@ article pre,
overflow-y: 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;
aspect-ratio: 16 / 9;
}
.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 {
.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(),
],
},

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

@ -11,6 +11,9 @@ video-sources:
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

Просмотреть файл

@ -11,6 +11,9 @@ video-sources:
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

Просмотреть файл

@ -11,6 +11,9 @@ video-sources:
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

Просмотреть файл

Просмотреть файл

@ -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

Просмотреть файл

@ -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,29 +140,46 @@ 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,
);
let response;
try {
response = await fetch(downloadUrl, fetchOpts);
} catch (e) {
this.warning(`Pausing the download of ${downloadUrl} due to network error.`);
this.forcePause();
return;
/**
* 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 reader = response.body.getReader();
@ -146,6 +188,11 @@ export default class DownloadManager {
? 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;
@ -155,7 +202,7 @@ export default class DownloadManager {
/* eslint-disable-next-line no-await-in-loop */
dataChunk = await reader.read();
} catch (e) {
this.warning(`Pausing the download of ${downloadUrl} due to network error.`);
this.warning(`Pausing the download of ${rewrittenUrl} due to network error.`);
this.forcePause();
}
@ -201,10 +248,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();
}
}
}

Просмотреть файл

@ -119,6 +119,7 @@ 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.

Просмотреть файл

@ -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,49 +109,11 @@ 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');
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(':');
/**
@ -79,52 +125,131 @@ export default (routerContext) => {
* @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.
|| 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 class="video-article ${isVideoAvailable(downloader) ? '' : '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 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');
@ -135,7 +260,6 @@ export default (routerContext) => {
}
});
const playButton = mainContent.querySelector('.play');
const categorySlug = currentVideoData.categories[0];
const { name, slug } = apiData.categories.find((obj) => obj.slug === categorySlug);
const localContext = {
@ -154,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(downloader);
videoContainer.classList.add('has-player');
videoPlayer.render(currentVideoData);
playerWrapper.appendChild(videoPlayer);
videoPlayer.play();
});
};

Просмотреть файл

@ -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}
*/

Просмотреть файл

@ -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 0100.
*/
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,16 +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 } from '../../constants';
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 }) {
@ -38,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;
}
/**
@ -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.
*
@ -117,6 +133,7 @@ export default class VideoDownloader extends HTMLElement {
const currentState = {
state: this.state,
willremove: this.willremove,
nocontrols: this.nocontrols,
};
if (Object.keys(currentState).includes(name)) {
@ -138,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,
@ -147,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();
}
/**
@ -253,47 +260,45 @@ export default class VideoDownloader extends HTMLElement {
if (!opts.assetsOnly) {
this.downloading = true;
this.runIDBDownloads();
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 0100.
*/
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') {
/**
@ -321,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();
}
@ -487,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';

Просмотреть файл

@ -72,6 +72,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.
*
@ -88,7 +102,10 @@ 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>
@ -108,6 +125,9 @@ 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);
@ -386,18 +406,35 @@ 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,
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();
}
});
}
@ -410,6 +447,8 @@ export default class extends HTMLElement {
if (!('pictureInPictureEnabled' in document)) {
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 = () => {
@ -419,7 +458,7 @@ export default class extends HTMLElement {
};
pipButton.setAttribute('aria-label', 'Toggle picture in picture');
pipButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.25 15L20.25 21C20.25 21.3978 20.092 21.7794 19.8107 22.0607C19.5294 22.342 19.1478 22.5 18.75 22.5L3 22.5C2.60218 22.5 2.22064 22.342 1.93934 22.0607C1.65804 21.7794 1.5 21.3978 1.5 21L1.5 8.25C1.5 7.85217 1.65804 7.47064 1.93934 7.18934C2.22064 6.90803 2.60218 6.75 3 6.75L6.75 6.75" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.75 3L9.75 10.5C9.75 11.3284 10.4216 12 11.25 12L21 12C21.8284 12 22.5 11.3284 22.5 10.5L22.5 3C22.5 2.17157 21.8284 1.5 21 1.5L11.25 1.5C10.4216 1.5 9.75 2.17157 9.75 3Z" stroke="var(--accent)" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="square"/><path d="M9 18.75V15H5.25" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.25 18.75L9 15" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
pipButton.innerHTML = ENTER_PIP_SVG;
pipButton.addEventListener('click', async () => {
pipButton.disabled = true;
@ -439,8 +478,14 @@ export default class extends HTMLElement {
this.videoElement.addEventListener('loadedmetadata', setPipButton);
this.videoElement.addEventListener('emptied', setPipButton);
this.videoElement.addEventListener('enterpictureinpicture', () => this.classList.add(PIP_CLASSNAME));
this.videoElement.addEventListener('leavepictureinpicture', () => this.classList.remove(PIP_CLASSNAME));
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();