Skip to content
This repository has been archived by the owner on Jan 15, 2020. It is now read-only.

Commit

Permalink
Adds support for background fetch
Browse files Browse the repository at this point in the history
  • Loading branch information
paullewis committed Apr 10, 2017
1 parent b1ece13 commit 36bb264
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 94 deletions.
3 changes: 2 additions & 1 deletion build/transpile-javascript.js
Expand Up @@ -42,7 +42,8 @@ const entries = [
'client/scripts/app.js',
'client/scripts/downloads.js',
'client/scripts/settings.js',
'client/scripts/ranged-response.js'
'client/scripts/ranged-response.js',
'client/scripts/background-fetch-helper.js'
];

let cache;
Expand Down
118 changes: 118 additions & 0 deletions src/client/scripts/background-fetch-helper.js
@@ -0,0 +1,118 @@
/**
*
* Copyright 2017 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.
*/

'use strict';

// import Constants from './constants/constants';
import Utils from './helpers/utils';
import idbKeyval from '../third_party/libs/idb-keyval';

class BackgroundFetchHelper {

constructor () {
this._onBackgroundFetched = this._onBackgroundFetched.bind(this);
this._onBackgroundFetchFailed = this._onBackgroundFetchFailed.bind(this);

self.addEventListener('backgroundfetchfail',
this._onBackgroundFetchFailed);

self.addEventListener('backgroundfetched',
this._onBackgroundFetched);

this._fetchCallbacks = null;
}

fetch (tag, assets, callbacks) {
// Store the assets that are being background fetched because there may be
// a teardown in the SW and the responses will need to be hooked back
// up when the responses come in.
idbKeyval.set(tag, assets).then(_ => {
const requests = assets.map(asset => {
const {url, options} = asset.responseInfo;
if (options.headers) {
options.headers = new Headers(options.headers);
}

return new Request(url, options);
});

// TODO: Update these to be based on the downloaded show.
const options = {
title: 'Downloading show for offline.'
};
this._fetchCallbacks = callbacks;
registration.backgroundFetch.fetch(tag, requests, options);
});
}

_broadcastToAllClients () {
console.log('Broadcast to all.');
}

_onBackgroundFetched (evt) {
if (!this._fetchCallbacks) {
this._broadcastToAllClients();
return;
}

const tag = evt.tag;
idbKeyval.get(tag).then(assets => {
if (!assets) {
console.error('Unknown background fetch.');
return;
}

return caches.open(tag).then(cache => {
const fetches = evt.fetches;
return Promise.all(assets.map(asset => {
const fetch = fetches.find(r => {
return (r.response.url === asset.responseInfo.url ||
r.response.url.endsWith(asset.responseInfo.url));
});

if (!fetch) {
console.log(
`Unable to find response for ${asset.responseInfo.url}`);
return;
}

const response = fetch.response;
if (!asset.chunk) {
return cache.put(asset.request, response);
}

return Utils.cacheInChunks(cache, response);
}));
}).then(_ => {
this._fetchCallbacks.onBackgroundFetched.call();
this._teardown(evt.tag);
});
});
}

_onBackgroundFetchFailed (evt) {
this._fetchCallbacks.onBackgroundFetchFailed.call();
this._teardown(evt.tag);
}

_teardown (tag) {
this._fetchCallbacks = null;
return idbKeyval.delete(tag);
}
}

self.BackgroundFetchHelper = new BackgroundFetchHelper();
1 change: 1 addition & 0 deletions src/client/scripts/constants/constants.js
Expand Up @@ -25,6 +25,7 @@ const constants = {
CHUNK_SIZE: 1024 * 512,

SUPPORTS_CACHING: ('caches' in self),
SUPPORTS_BACKGROUND_FETCH: ('BackgroundFetchManager' in self),

// TODO: Make these based on user preference.
PREFETCH_VIDEO_HEIGHT: 480,
Expand Down
150 changes: 81 additions & 69 deletions src/client/scripts/helpers/offline-cache.js
Expand Up @@ -19,6 +19,7 @@

import Constants from '../constants/constants';
import LicensePersister from './license-persister';
import Utils from './utils';

class OfflineCache {

Expand Down Expand Up @@ -157,6 +158,15 @@ class OfflineCache {

constructor () {
this._cancel = new Set();

if (!Constants.SUPPORTS_BACKGROUND_FETCH) {
return;
}

this._serviceWorkerCallbacks = null;
this._onServiceWorkerMessage = this._onServiceWorkerMessage.bind(this);
navigator.serviceWorker.addEventListener('message',
this._onServiceWorkerMessage);
}

cancel (name) {
Expand Down Expand Up @@ -203,20 +213,31 @@ class OfflineCache {

assets.push({
request: `${assetPath}/${dest}`,
response: fetch(`${assetPath}/${src}`, {mode: 'cors'}),
response: null,
responseInfo: {
url: `${assetPath}/${src}`,
options: {
mode: 'cors'
}
},
chunk
});
});

// Ensure that the request for the page isn't gzipped in response.
const headers = new Headers();
headers.set('X-No-Compression', true);
const headers = {
'X-No-Compression': true
};

assets.push({
request: pagePath,
response: fetch(pagePath, {
headers
})
response: null,
responseInfo: {
url: pagePath,
options: {
headers
}
}
});

const tasks = [];
Expand All @@ -225,7 +246,20 @@ class OfflineCache {
tasks.push(LicensePersister.persist(name, drmInfo));
}

tasks.push(this._download(name, assets, callbacks));
if (Constants.SUPPORTS_BACKGROUND_FETCH) {
tasks.push(this._downloadBackground(name, assets, callbacks));
} else {
assets.forEach(asset => {
const {url, options} = asset.responseInfo;
if (options.headers) {
options.headers = new Headers(options.headers);
}

asset.response = fetch(url, options);
});

tasks.push(this._downloadForeground(name, assets, callbacks));
}

// Mark the download as being in-flight.
OfflineCache.addInFlight(name);
Expand Down Expand Up @@ -305,7 +339,7 @@ class OfflineCache {

fetches.push(makeRequest({path: manifestPath}));

return this._download('prefetch', fetches, {
return this._downloadForeground('prefetch', fetches, {
onProgressCallback () {},
onCompleteCallback () {
console.log(`Prefetched ${prefetchLimit}s.`);
Expand All @@ -320,7 +354,7 @@ class OfflineCache {
});
}

_download (name, fetches, callbacks) {
_downloadForeground (name, fetches, callbacks) {
name = OfflineCache.convertPathToName(name);

const downloads = Promise.all(fetches.map(r => r.response));
Expand All @@ -334,13 +368,50 @@ class OfflineCache {
return cache.put(asset.request, response);
}

return this._cacheInChunks(cache, response);
return Utils.cacheInChunks(cache, response);
});
}));
});
});
}

_downloadBackground (name, assets, callbacks) {
this._serviceWorkerCallbacks = callbacks;
navigator.serviceWorker.ready.then(registration => {
if (!registration.active) {
return;
}

console.log('Asking SW to background fetch assets');
registration.active.postMessage({
action: 'offline',
tag: name,
assets
});
});
}

_onServiceWorkerMessage (evt) {
const {offline, success, name} = evt.data;
// Ignore messages not intended for this.
if (!offline) {
return;
}

OfflineCache.removeInFlight(name);
if (!this._serviceWorkerCallbacks) {
return;
}

if (success) {
this._serviceWorkerCallbacks.onCompleteCallback();
} else {
this._serviceWorkerCallbacks.onCancelCallback();
}

this._serviceWorkerCallbacks = null;
}

_getManifest (manifestPath) {
return fetch(manifestPath).then(r => r.text()).then(dashManifest => {
const parser = new DOMParser();
Expand Down Expand Up @@ -488,65 +559,6 @@ class OfflineCache {
return ranges;
}

_cacheInChunks (cache, response) {
const clone = response.clone();
const reader = clone.body.getReader();
const contentRange = clone.headers.get('content-range');
const headers = new Headers(clone.headers);

// If we've made a range request we will now need to check the full
// length of the video file, and update the header accordingly. This
// will be for the case where we're prefetching the video, and we need
// to pretend that the entire file is available despite only part
// requesting the file.
if (contentRange) {
headers.set('Content-Length',
parseInt(contentRange.split('/')[1], 10));
}

let total = parseInt(response.headers.get('content-length'), 10);
let i = 0;
let buffer = new Uint8Array(Math.min(total, Constants.CHUNK_SIZE));
let bufferId = 0;

const commitBuffer = bufferOut => {
headers.set('x-chunk-size', bufferOut.byteLength);
const cacheId = clone.url + '_' + bufferId;
const chunkResponse = new Response(bufferOut, {
headers
});
cache.put(cacheId, chunkResponse);
};

const onStreamData = result => {
if (result.done) {
commitBuffer(buffer, bufferId);
return;
}

// Copy the bytes over.
for (let b = 0; b < result.value.length; b++) {
buffer[i++] = result.value[b];

if (i === Constants.CHUNK_SIZE) {
// Commit this buffer.
commitBuffer(buffer, bufferId);

// Reduce the expected amount, and go again.
total -= Constants.CHUNK_SIZE;
i = 0;
buffer = new Uint8Array(Math.min(total, Constants.CHUNK_SIZE));
bufferId++;
}
}

// Get the next chunk.
return reader.read().then(onStreamData);
};

reader.read().then(onStreamData);
}

_trackDownload (name, responses, {
onProgressCallback,
onCompleteCallback,
Expand Down

0 comments on commit 36bb264

Please sign in to comment.