Offline Storage and Playback
Overview
This tutorial walks you through the main methods for Shaka Player’s offline support. After this tutorial you will know how to:
- Download content.
- List downloaded content.
- Play downloaded content.
- Remove downloaded content.
- Pause and resume downloads.
- Play content while it is being downloaded.
The app in this tutorial is very simple and only downloads one thing at a time,
to simplify the UI. shaka.offline.Storage will allow you to perform multiple
downloads at once.
Offline API
This tutorial uses the shaka.offline.Storage API. The methods we will use
are:
- configure
- store
- list
- remove
More information on these methods can be found in the shaka.offline.Storage API.
Starting Code
There are two files you need for this tutorial. These files provide the HTML UI and UI logic. We will resolve all the TODOs in the code during the tutorial using the shaka.offline.Storage API. The complete code is available at the end of the tutorial.
<!DOCTYPE html>
<html>
<head>
<!-- Shaka Player compiled library: -->
<script src="dist/shaka-player.compiled.js"></script>
<!-- Your application source: -->
<script src="myapp.js"></script>
<style>
table, th, td {
border: 1px solid black;
}
</style>
</head>
<body>
<div id='online-signal' style='width:640px;text-align:center'></div>
<div>
<div>
<span style="width:120px;display:inline-block">Asset Name</span>
<input id="asset-title-input" type="text" style="width:500px" value="Star Trek: Angel One">
</div>
<div>
<span style="width:120px;display:inline-block">Asset Manifest</span>
<input id="asset-uri-input" type="text" style="width:500px" value="//storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd">
</div>
</div>
<div>
<span><progress id="progress-bar" value="0" max="100"></span>
<span><button id="download-button">Download</button></span>
</div>
<video id="video"
width="640"
controls autoplay></video>
<table id="content-table" style="width:640px"></table>
</body>
</html>
// myapp.js
async function initApp() {
// Install built-in polyfills to patch browser incompatibilities.
shaka.polyfill.installAll();
// Check to see if the browser supports the basic APIs Shaka needs.
if (shaka.Player.isBrowserSupported()) {
// Everything looks good!
await initPlayer();
} else {
// This browser does not have the minimum set of APIs we need.
console.error('Browser not supported!');
}
// Update the online status and add listeners so that we can visualize
// our network state to the user.
updateOnlineStatus();
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
}
async function initPlayer() {
// Create a Player instance.
const video = document.getElementById('video');
const player = new shaka.Player();
await player.attach(video);
// Attach player and storage to the window to make it easy to access
// in the JS console and so we can access it in other methods.
window.player = player;
// Listen for error events.
player.addEventListener('error', onErrorEvent);
initStorage(player);
const downloadButton = document.getElementById('download-button');
downloadButton.onclick = onDownloadClick;
// Update the content list to show what items we initially have
// stored offline.
refreshContentList();
}
function onErrorEvent(event) {
// Extract the shaka.util.Error object from the event.
onError(event.detail);
}
function onError(error) {
// Log the error.
console.error('Error code', error.code, 'object', error);
}
function selectTracks(tracks) {
// This example stores the highest bandwidth variant.
//
// Note that this is just an example of an arbitrary algorithm, and not a best
// practice for storing content offline. Decide what your app needs, or keep
// the default (user-pref-matching audio, best SD video, all text).
const found = tracks
.filter(function(track) { return track.type == 'variant'; })
.sort(function(a, b) { return a.bandwidth - b.bandwidth; })
.pop();
console.log('Offline Track bandwidth: ' + found.bandwidth);
return [ found ];
}
function initStorage(player) {
// TODO : Initialize storage.
}
function listContent() {
// TODO : return all downloaded content.
}
function playContent(content) {
// TODO : play offline content.
}
function removeContent(content) {
// TODO : remove content from storage.
}
function downloadContent(manifestUri, title) {
// TODO : save content with storage.
}
/*
* UI callback for when the download button is clicked. This will
* disable the button while the download is in progress, start the
* download, and refresh the content list once the download is
* complete.
*/
function onDownloadClick() {
const downloadButton = document.getElementById('download-button');
const manifestUri = document.getElementById('asset-uri-input').value;
const title = document.getElementById('asset-title-input').value;
// Disable the download button to prevent user from requesting
// another download until this download is complete.
downloadButton.disabled = true;
setDownloadProgress(null, 0);
// Download the content and then re-enable the download button so
// that more content can be downloaded.
downloadContent(manifestUri, title)
.then(function() {
return refreshContentList();
})
.then(function(content) {
setDownloadProgress(null, 1);
downloadButton.disabled = false;
})
.catch(function(error) {
// In the case of an error, re-enable the download button so
// that the user can try to download another item.
downloadButton.disabled = false;
onError(error);
});
}
/*
* Update the online status box at the top of the page to tell the
* user whether or not they have an internet connection.
*/
function updateOnlineStatus() {
const signal = document.getElementById('online-signal');
if (navigator.onLine) {
signal.innerHTML = 'ONLINE';
signal.style.background = 'green';
} else {
signal.innerHTML = 'OFFLINE';
signal.style.background = 'grey';
}
}
/*
* Find our progress bar and set the value to show the progress we
* have made.
*/
function setDownloadProgress(content, progress) {
const progressBar = document.getElementById('progress-bar');
progressBar.value = progress * progressBar.max;
}
/*
* Clear our content table and repopulate it table with the current
* list of downloaded content.
*/
function refreshContentList() {
const contentTable = document.getElementById('content-table');
// Clear old rows from the table.
while (contentTable.rows.length) {
contentTable.deleteRow(0);
}
const addRow = function(content) {
const append = -1;
const row = contentTable.insertRow(append);
row.insertCell(append).innerHTML = content.offlineUri;
Object.keys(content.appMetadata)
.map(function(key) {
return content.appMetadata[key];
})
.forEach(function(value) {
row.insertCell(append).innerHTML = value;
});
row.insertCell(append).appendChild(createButton(
'PLAY',
function() { playContent(content); }));
row.insertCell(append).appendChild(createButton(
'REMOVE',
function() {
removeContent(content)
.then(function() { refreshContentList() });
}));
};
return listContent()
.then(function(content) { content.forEach(addRow); });
};
/*
* Create a new button but do not add it to the DOM. The caller
* will need to do that.
*/
function createButton(text, action) {
const button = document.createElement('button');
button.innerHTML = text;
button.onclick = action;
return button;
}
document.addEventListener('DOMContentLoaded', initApp);
Initializing Storage
The first step is to initialize our storage instance. Since we are downloading
content one at a time, we will use a single instance of shaka.offline.Storage.
Resolve the TODO in “initStorage” labeled “Initialize storage” with the following code:
// Create a storage instance and configure it with optional
// callbacks. Set the progress callback so that we visualize
// download progress and override the track selection callback.
window.storage = new shaka.offline.Storage(player);
window.storage.configure({
offline: {
progressCallback: setDownloadProgress,
trackSelectionCallback: selectTracks
}
});
We assign window.storage to our storage instance so that it can be accessed
elsewhere in our tutorial. Calling storage.configure is optional. We are
using it to set a custom progress callback and track selection function. The
progress callback allows us to visualize the storage component’s progress
when downloading and removing content. The track selection callback lets us
limit which tracks to download.
Now that we have initialized our storage instance, we are ready to download content.
Downloading Content
Now that we have initialized storage we can download content. Resolve the TODO in “downloadContent” labeled “save content with storage” with the following code:
// Construct a metadata object to be stored alongside the content.
// This can hold any information the app wants to be stored with
// the content.
const metadata = {
'title': title,
'downloaded': new Date()
};
return window.storage.store(manifestUri, metadata);
Storage allows us to store metadata alongside our content. We are going to save the title of the content and the time we downloaded it, but the metadata can contain anything you want. The metadata is optional, so you can ignore it if you want.
storage.store returns an IAbortableOperation that resolves to a
shaka.externs.StoredContent instance (a summary of the stored content).
At this point, the content is now stored offline and it's ready to be played. Next we will add functionality to play offline content.
Note: If you call storage.store twice with the same manifestUri as input,
you'll download the same manifestUri twice.
Playing Offline Content
Now that we have stored some content, we want to play it. To do this, resolve the TODO in “playContent” labeled “play offline content” with:
window.player.load(content.offlineUri);
Yes, that is really all there is to play offline content. The player actually
does not know that the content is offline. Behind the scenes, Shaka Player’s
networking layer is redirecting the networking requests to offline storage.
So all you need to do is pass the offline uri from any stored content instance
to player.load and it will start playing.
Next, we need to list the content that has already been stored.
Listing Offline Content
Next we are going to resolve the TODO in “listContent” labeled “return all downloaded content” with:
return window.storage.list();
Once again Shaka Player makes it easy for you. All you need is to call
storage.list and you will get a Promise that resolves to a
shaka.extern.StoredContent Array reflecting all content you have stored
offline.
Now you should be able to download content and play content.
Removing Offline Content
Next, we need to be able to remove content from storage. After all, there is limited space and so much great content out there. Resolve the TODO in “removeContent” labeled “remove content from storage” with:
return window.storage.remove(content.offlineUri);
All you need to do is pass storage.remove the URI of the offline content and
that content will be removed from storage. If you remember the progress callback
we set earlier, that will get called during the removal so that you can
visualize the removal progress.
Final Code
That’s it! For your convenience, here is the completed code:
<!DOCTYPE html>
<html>
<head>
<!-- Shaka Player compiled library: -->
<script src="dist/shaka-player.compiled.js"></script>
<!-- Your application source: -->
<script src="myapp.js"></script>
<style>
table, th, td {
border: 1px solid black;
}
</style>
</head>
<body>
<div id='online-signal' style='width:640px;text-align:center'></div>
<div>
<div>
<span style="width:120px;display:inline-block">Asset Name</span>
<input id="asset-title-input" type="text" style="width:500px" value="Star Trek: Angel One">
</div>
<div>
<span style="width:120px;display:inline-block">Asset Manifest</span>
<input id="asset-uri-input" type="text" style="width:500px" value="//storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd">
</div>
</div>
<div>
<span><progress id="progress-bar" value="0" max="100"></span>
<span><button id="download-button">Download</button></span>
</div>
<video id="video"
width="640"
controls autoplay></video>
<table id="content-table" style="width:640px"></table>
</body>
</html>
// myapp.js
async function initApp() {
// Install built-in polyfills to patch browser incompatibilities.
shaka.polyfill.installAll();
// Check to see if the browser supports the basic APIs Shaka needs.
if (shaka.Player.isBrowserSupported()) {
// Everything looks good!
await initPlayer();
} else {
// This browser does not have the minimum set of APIs we need.
console.error('Browser not supported!');
}
// Update the online status and add listeners so that we can visualize
// our network state to the user.
updateOnlineStatus();
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
}
async function initPlayer() {
// Create a Player instance.
const video = document.getElementById('video');
const player = new shaka.Player();
await player.attach(video);
// Attach player and storage to the window to make it easy to access
// in the JS console and so we can access it in other methods.
window.player = player;
// Listen for error events.
player.addEventListener('error', onErrorEvent);
initStorage(player);
const downloadButton = document.getElementById('download-button');
downloadButton.onclick = onDownloadClick;
// Update the content list to show what items we initially have
// stored offline.
refreshContentList();
}
function onErrorEvent(event) {
// Extract the shaka.util.Error object from the event.
onError(event.detail);
}
function onError(error) {
// Log the error.
console.error('Error code', error.code, 'object', error);
}
function selectTracks(tracks) {
// This example stores the highest bandwidth variant.
//
// Note that this is just an example of an arbitrary algorithm, and not a best
// practice for storing content offline. Decide what your app needs, or keep
// the default (user-pref-matching audio, best SD video, all text).
const found = tracks
.filter(function(track) { return track.type == 'variant'; })
.sort(function(a, b) { return a.bandwidth - b.bandwidth; })
.pop();
console.log('Offline Track bandwidth: ' + found.bandwidth);
return [ found ];
}
function initStorage(player) {
// Create a storage instance and configure it with optional
// callbacks. Set the progress callback so that we visualize
// download progress and override the track selection callback.
window.storage = new shaka.offline.Storage(player);
window.storage.configure({
offline: {
progressCallback: setDownloadProgress,
trackSelectionCallback: selectTracks
}
});
}
function listContent() {
return window.storage.list();
}
function playContent(content) {
window.player.load(content.offlineUri);
}
function removeContent(content) {
return window.storage.remove(content.offlineUri);
}
function downloadContent(manifestUri, title) {
// Construct a metadata object to be stored along side the content.
// This can hold any information the app wants to be stored with the
// content.
const metadata = {
'title': title,
'downloaded': Date()
};
return window.storage.store(manifestUri, metadata).promise;
}
/*
* UI callback for when the download button is clicked. This will
* disable the button while the download is in progress, start the
* download, and refresh the content list once the download is
* complete.
*/
function onDownloadClick() {
const downloadButton = document.getElementById('download-button');
const manifestUri = document.getElementById('asset-uri-input').value;
const title = document.getElementById('asset-title-input').value;
// Disable the download button to prevent user from requesting
// another download until this download is complete.
downloadButton.disabled = true;
setDownloadProgress(null, 0);
// Download the content and then re-enable the download button so
// that more content can be downloaded.
downloadContent(manifestUri, title)
.then(function() {
return refreshContentList();
})
.then(function(content) {
setDownloadProgress(null, 1);
downloadButton.disabled = false;
})
.catch(function(error) {
// In the case of an error, re-enable the download button so
// that the user can try to download another item.
downloadButton.disabled = false;
onError(error);
});
}
/*
* Update the online status box at the top of the page to tell the
* user whether or not they have an internet connection.
*/
function updateOnlineStatus() {
const signal = document.getElementById('online-signal');
if (navigator.onLine) {
signal.innerHTML = 'ONLINE';
signal.style.background = 'green';
} else {
signal.innerHTML = 'OFFLINE';
signal.style.background = 'grey';
}
}
/*
* Find our progress bar and set the value to show the progress we
* have made.
*/
function setDownloadProgress(content, progress) {
const progressBar = document.getElementById('progress-bar');
progressBar.value = progress * progressBar.max;
}
/*
* Clear our content table and repopulate it table with the current
* list of downloaded content.
*/
function refreshContentList() {
const contentTable = document.getElementById('content-table');
// Clear old rows from the table.
while (contentTable.rows.length) {
contentTable.deleteRow(0);
}
const addRow = function(content) {
const append = -1;
const row = contentTable.insertRow(append);
row.insertCell(append).innerHTML = content.offlineUri;
Object.keys(content.appMetadata)
.map(function(key) {
return content.appMetadata[key];
})
.forEach(function(value) {
row.insertCell(append).innerHTML = value;
});
row.insertCell(append).appendChild(createButton(
'PLAY',
function() { playContent(content); }));
row.insertCell(append).appendChild(createButton(
'REMOVE',
function() {
removeContent(content)
.then(function() { refreshContentList() });
}));
};
return listContent()
.then(function(content) { content.forEach(addRow); });
};
/*
* Create a new button but do not add it to the DOM. The caller
* will need to do that.
*/
function createButton(text, action) {
const button = document.createElement('button');
button.innerHTML = text;
button.onclick = action;
return button;
}
document.addEventListener('DOMContentLoaded', initApp);
Pausing and Resuming Downloads
A download can be paused with storage.pause, and resumed later with
storage.resume, even after the page has been closed and opened again.
To pause a download, you need the offline URI of the content. It is set in the
shaka.extern.StoredContent given to the progress callback as soon as the
content has been created in storage, before its segments are downloaded:
let offlineUri = null;
storage.configure('offline.progressCallback', (content, progress) => {
offlineUri = content.offlineUri;
});
const operation = storage.store(manifestUri, metadata);
// Later, while the content is being downloaded:
await storage.pause(offlineUri);
Once the download has been paused, the operation returned by storage.store
resolves with the stored content, with isIncomplete set to true. The
segments downloaded so far are kept, and the content is returned by
storage.list like any other content. Incomplete content can be played up to
the segments stored so far; see
Playing Content While It Downloads.
To resume the download, call storage.resume with the offline URI of the
content:
const content = await storage.resume(offlineUri).promise;
The manifest is downloaded again, and only the segments that are not stored yet
are downloaded. storage.resume returns an IAbortableOperation, just like
storage.store, and the progress callback is called with the progress of the
whole content. If the manifest needs a MIME type to be parsed, pass the same
one given to storage.store as the second argument.
The tracks stored are the ones chosen when the download started, so they must
still be in the manifest. If they are not, the operation fails with
CANNOT_RESUME_CHANGED_CONTENT, and the content can only be removed.
Note: Aborting the operation returned by storage.store or storage.resume
removes the content. Pause it instead to keep what has been downloaded.
Interrupted Downloads
If the page is closed or the app is killed while content is being downloaded,
the content stays in storage with isIncomplete set to true. The segments
downloaded so far are kept, so the download can be resumed with storage.resume
the next time the page is opened, just like a paused download:
for (const content of await storage.list()) {
if (content.isIncomplete) {
storage.resume(content.offlineUri);
}
}
A download running in one page cannot be resumed in another page of the same
origin at the same time; storage.resume fails with
CANNOT_RESUME_WHILE_STORING. This needs the
Web Locks API
to detect downloads running in other pages. Without it, only downloads running
in the same page are detected.
The last segments written before the page was closed may not be referenced by
the content yet, and are left in storage, taking up space. To remove them,
call storage.removeOrphans when the app starts, which also removes the
persistent licenses of content that failed to be removed:
await storage.removeOrphans();
It fails with CANNOT_REMOVE_ORPHANS_WHILE_STORING while content is being
downloaded, since the segments of that download may not be referenced by the
content yet either.
Playing Content While It Downloads
Content can be played while it is being downloaded, as soon as its offline URI is known. Load it like any other offline content:
let offlineUri = null;
storage.configure('offline.progressCallback', (content, progress) => {
offlineUri = content.offlineUri;
});
const operation = storage.store(manifestUri, metadata);
// Later, once offlineUri is set:
await player.load(offlineUri);
The segments that are already stored are played right away. When the player needs a segment that is not stored yet, it waits for it to be stored, just as it would wait for a slow network. The segments of each track are downloaded in order, so playback from the start keeps up with the download, but seeking far ahead waits until the download gets there.
The download can run in the same page or, where the Web Locks API is available, in another page of the same origin.
If the content is not being downloaded, for example because the download was
paused or the page that downloaded it was closed, the player cannot wait for
the missing segments. Playing up to them works, but then the player raises a
SEGMENT_NOT_STORED error for each track that needs them, and stops streaming.
Once the download is resumed, call player.retryStreaming() to continue:
player.addEventListener('error', (event) => {
if (event.detail.code == shaka.util.Error.Code.SEGMENT_NOT_STORED) {
// Offer to resume the download.
}
});
// Later, when the download is resumed:
storage.resume(offlineUri);
player.retryStreaming();
Note: The tracks, and for protected content, the licenses, are those stored when the content is loaded. If the init data of protected content is only in its segments, its licenses are stored as those segments are downloaded, so it may not be playable until its download is complete.
Protected Content
When storing protected content offline, there are some limitations based on browsers. Persistent licenses are only supported on Android (M62+) and Chromebooks. Chrome v64 to v142 did support persistent licenses on Windows and Mac as well.
For other platforms, we offer the ability to disable the use of persistent licenses. If you choose to disable persistent licenses, you will get offline storage of protected content on all DRM-enabled browsers, at the cost of needing a network connection at playback time to retrieve licenses. Therefore, you should avoid this setting on browsers that support persistent licenses.
If you want to download content but not persistent licenses, when you configure storage, set:
usePersistentLicense: false
By default, shaka.offline.Storage stores persistent licenses. If you want this
behaviour and you know you are on a supported platform, you can omit the
setting or set it explicitly with:
usePersistentLicense: true
Configure the timeout for opening IndexedDB-based storage
There is a default timeout of 5 seconds for opening IndexedDB-based storage. This is useful to ensure that your application does not block indefinitely while waiting for the IndexedDB database to open.
If you want to change the value of this timeout, you can do so by setting
accordingly the value of the shaka.offline.indexeddb.StorageMechanismOpenTimeout
variable. For example, to set the timeout to 10 seconds, you can do the following:
// In seconds
shaka.offline.indexeddb.StorageMechanismOpenTimeout = 10;
You can also disable this timeout and consequently wait indefinitely for the
IndexedDB database to open successfully or fail to open. To do this, set the
shaka.offline.indexeddb.StorageMechanismOpenTimeout variable to false:
shaka.offline.indexeddb.StorageMechanismOpenTimeout = false;
Note that this configuration must be done before doing any other offline storage related operation (download and store content, list content, remove content, playback of content).