diff --git a/build/types/ui b/build/types/ui index f7b54b7f6..568417a86 100644 --- a/build/types/ui +++ b/build/types/ui @@ -26,6 +26,7 @@ +../../ui/language_utils.js +../../ui/localization.js +../../ui/loop_button.js ++../../ui/media_session.js +../../ui/mute_button.js +../../ui/overflow_menu.js +../../ui/pip_button.js diff --git a/demo/cast_receiver/receiver_app.js b/demo/cast_receiver/receiver_app.js index 6e04014ee..f9242c548 100644 --- a/demo/cast_receiver/receiver_app.js +++ b/demo/cast_receiver/receiver_app.js @@ -58,6 +58,9 @@ class ShakaReceiverApp { this.receiver_ = new shaka.cast.CastReceiver( this.video_, this.player_, (appData) => this.appDataCallback_(appData)); + + ui.getControls().setCastReceiver(this.receiver_); + this.receiver_.addEventListener( 'caststatuschanged', () => this.checkIdle_()); @@ -69,50 +72,6 @@ class ShakaReceiverApp { this.video_.removeAttribute('poster'); }); - // Setup content image and title - this.player_.addEventListener('metadata', (event) => { - const payload = event['payload']; - if (!payload) { - return; - } - let title; - if (payload['key'] == 'TIT2' && payload['data']) { - title = payload['data']; - } - let imageUrl; - if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') { - imageUrl = payload['data']; - } - if (title) { - this.receiver_.setContentTitle(title); - } - if (imageUrl) { - this.receiver_.setContentImage(imageUrl); - } - }); - this.player_.addEventListener('sessiondata', (event) => { - const id = event['id']; - switch (id) { - case 'com.apple.hls.title': { - const title = event['value']; - if (title) { - this.receiver_.setContentTitle(title); - } - break; - } - case 'com.apple.hls.poster': { - let imageUrl = event['value']; - if (imageUrl) { - imageUrl = imageUrl.replace('{w}', '512') - .replace('{h}', '512') - .replace('{f}', 'jpeg'); - this.receiver_.setContentImage(imageUrl); - } - break; - } - } - }); - this.startIdleTimer_(); } diff --git a/demo/main.js b/demo/main.js index d58c65169..64c8654be 100644 --- a/demo/main.js +++ b/demo/main.js @@ -1274,7 +1274,7 @@ shakaDemo.Main = class { this.player_.unload(); - const queueManager = this.player_.getQueueManager(); + const queueManager = this.controls_.getQueueManager(); queueManager.removeAllItems(); // The currently-selected asset changed, so update asset cards. @@ -1412,7 +1412,7 @@ shakaDemo.Main = class { ui.configure(uiConfig); } - const queueManager = this.player_.getQueueManager(); + const queueManager = this.controls_.getQueueManager(); await queueManager.removeAllItems(); if (asset.hasAds()) { @@ -1492,7 +1492,7 @@ shakaDemo.Main = class { * @param {ShakaDemoAssetInfo} asset */ async addToQueue(asset) { - const queueManager = this.player_.getQueueManager(); + const queueManager = this.controls_.getQueueManager(); const queueItem = await this.getQueueItem_(asset); queueManager.insertItems([queueItem]); } diff --git a/ui/controls.js b/ui/controls.js index 9f8cdc431..9fb751c7e 100644 --- a/ui/controls.js +++ b/ui/controls.js @@ -21,6 +21,7 @@ goog.require('shaka.ui.HiddenFastForwardButton'); goog.require('shaka.ui.HiddenRewindButton'); goog.require('shaka.ui.Locales'); goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.MediaSession'); goog.require('shaka.ui.SeekBar'); goog.require('shaka.ui.SkipAdButton'); goog.require('shaka.ui.Utils'); @@ -31,9 +32,9 @@ goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.FakeEventTarget'); goog.require('shaka.util.IDestroyable'); goog.require('shaka.util.Timer'); -goog.require('shaka.util.TXml'); goog.requireType('shaka.Player'); +goog.requireType('shaka.cast.CastReceiver'); /** @@ -147,6 +148,9 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { video, player, this.config_.castReceiverAppId, this.config_.castAndroidReceiverCompatible); + /** @private {?shaka.cast.CastReceiver} */ + this.castReceiver_ = null; + /** @private {boolean} */ this.castAllowed_ = true; @@ -301,7 +305,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { // Configure and create the layout of the controls this.configure(this.config_); this.addEventListeners_(); - this.setupMediaSession_(); /** * The pressed keys set is used to record which keys are currently pressed @@ -365,6 +368,9 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.togglePiP(); } }); + + /** @private {shaka.ui.MediaSession} */ + this.mediaSession_ = new shaka.ui.MediaSession(this); } /** @@ -450,7 +456,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.localization_ = null; this.pressedKeys_.clear(); - this.removeMediaSession_(); + if (this.mediaSession_) { + this.mediaSession_.release(); + this.mediaSession_ = null; + } // FakeEventTarget implements IReleasable super.release(); @@ -574,6 +583,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.eventManager_.listen(element, 'touchend', touchCb); } } + + if (this.mediaSession_) { + this.mediaSession_.configure(this.config_); + } } /** @@ -621,6 +634,14 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } + /** + * @param {!shaka.cast.CastReceiver} receiver + * @export + */ + setCastReceiver(receiver) { + this.castReceiver_ = receiver; + } + /** * @export * @return {?shaka.extern.IAd} @@ -653,6 +674,14 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.castProxy_; } + /** + * @export + * @return {?shaka.cast.CastReceiver} + */ + getCastReceiver() { + return this.castReceiver_; + } + /** * @return {shaka.ui.Localization} * @export @@ -709,6 +738,22 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.adManager_; } + /** + * @return {shaka.extern.IQueueManager} + * @export + */ + getQueueManager() { + return this.queueManager_; + } + + /** + * @return {shaka.ui.MediaSession} + * @export + */ + getMediaSession() { + return this.mediaSession_; + } + /** * @return {!HTMLElement} * @export @@ -1573,333 +1618,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } - /** - * @private - */ - setupMediaSession_() { - if (!this.config_.setupMediaSession || !navigator.mediaSession) { - return; - } - const addMediaSessionHandler = (type, callback) => { - try { - navigator.mediaSession.setActionHandler(type, callback); - } catch (error) { - shaka.log.debug( - `The "${type}" media session action is not supported.`); - } - }; - const updatePositionState = () => { - if (this.ad_ && this.ad_.isLinear()) { - clearPositionState(); - return; - } - const seekRange = this.player_.seekRange(); - let duration = seekRange.end - seekRange.start; - const position = parseFloat( - (this.video_.currentTime - seekRange.start).toFixed(2)); - if (this.player_.isLive() && Math.abs(duration - position) < 1) { - // Positive infinity indicates media without a defined end, such as a - // live stream. - duration = Infinity; - } - try { - navigator.mediaSession.setPositionState({ - duration: Math.max(0, duration), - playbackRate: this.video_.playbackRate, - position: Math.max(0, position), - }); - } catch (error) { - shaka.log.v2( - 'setPositionState in media session is not supported.'); - } - }; - const clearPositionState = () => { - try { - navigator.mediaSession.setPositionState(); - } catch (error) { - shaka.log.v2( - 'setPositionState in media session is not supported.'); - } - }; - const commonHandler = (details) => { - const keyboardSeekDistance = this.config_.keyboardSeekDistance; - switch (details.action) { - case 'pause': - this.playPausePresentation(); - break; - case 'play': - this.playPausePresentation(); - break; - case 'seekbackward': - if (details.seekOffset && !isFinite(details.seekOffset)) { - break; - } - if (!this.ad_ || !this.ad_.isLinear()) { - this.seek_(this.seekBar_.getValue() - - (details.seekOffset || keyboardSeekDistance)); - } - break; - case 'seekforward': - if (details.seekOffset && !isFinite(details.seekOffset)) { - break; - } - if (!this.ad_ || !this.ad_.isLinear()) { - this.seek_(this.seekBar_.getValue() + - (details.seekOffset || keyboardSeekDistance)); - } - break; - case 'seekto': - if (details.seekTime && !isFinite(details.seekTime)) { - break; - } - if (!this.ad_ || !this.ad_.isLinear()) { - this.seek_(this.player_.seekRange().start + details.seekTime); - } - break; - case 'stop': - this.player_.unload(); - break; - case 'enterpictureinpicture': - if (!this.ad_ || !this.ad_.isLinear()) { - this.togglePiP(); - } - break; - case 'nexttrack': - this.queueManager_.playItem( - this.queueManager_.getCurrentItemIndex() + 1); - break; - case 'previoustrack': - this.queueManager_.playItem( - this.queueManager_.getCurrentItemIndex() - 1); - break; - case 'skipad': - if (this.ad_) { - this.ad_.skip(); - } - break; - } - }; - - addMediaSessionHandler('pause', commonHandler); - addMediaSessionHandler('play', commonHandler); - addMediaSessionHandler('seekbackward', commonHandler); - addMediaSessionHandler('seekforward', commonHandler); - addMediaSessionHandler('seekto', commonHandler); - addMediaSessionHandler('stop', commonHandler); - if ('documentPictureInPicture' in window || - document.pictureInPictureEnabled) { - addMediaSessionHandler('enterpictureinpicture', commonHandler); - } - - // eslint-disable-next-line no-restricted-syntax - const supportsChapterInfo = 'chapterInfo' in MediaMetadata.prototype; - - const getMediaMetadata = () => { - const metadata = { - title: '', - artist: '', - album: '', - artwork: [], - }; - if (supportsChapterInfo) { - metadata.chapterInfo = []; - } - if (navigator.mediaSession.metadata) { - metadata.title = navigator.mediaSession.metadata.title; - metadata.artist = navigator.mediaSession.metadata.artist; - metadata.album = navigator.mediaSession.metadata.album; - metadata.artwork = navigator.mediaSession.metadata.artwork; - if (supportsChapterInfo) { - metadata.chapterInfo = navigator.mediaSession.metadata.chapterInfo; - } - } - return metadata; - }; - - const setupTitle = (title) => { - const metadata = getMediaMetadata(); - metadata.title = title; - navigator.mediaSession.metadata = new MediaMetadata(metadata); - }; - - const setupPoster = (imageUrl) => { - const video = /** @type {HTMLVideoElement} */ (this.localVideo_); - if (imageUrl != video.poster) { - video.poster = imageUrl; - } - const metadata = getMediaMetadata(); - metadata.artwork = [{src: imageUrl}]; - navigator.mediaSession.metadata = new MediaMetadata(metadata); - }; - - const setupChapters = () => { - if (!supportsChapterInfo) { - return; - } - const chapterInfo = []; - for (const chapter of this.chapters_) { - chapterInfo.push({ - title: chapter.title, - startTime: chapter.startTime, - artwork: [], - }); - } - const metadata = getMediaMetadata(); - metadata.chapterInfo = chapterInfo; - navigator.mediaSession.metadata = new MediaMetadata(metadata); - }; - - const playerLoaded = () => { - if (this.player_.isLive() || this.player_.seekRange().start != 0) { - updatePositionState(); - this.eventManager_.listen( - this.video_, 'timeupdate', updatePositionState); - } else { - clearPositionState(); - } - }; - const playerUnloading = () => { - this.eventManager_.unlisten( - this.video_, 'timeupdate', updatePositionState); - navigator.mediaSession.metadata = new MediaMetadata({}); - }; - - if (this.player_.isFullyLoaded()) { - playerLoaded(); - } - this.eventManager_.listen(this.player_, 'loaded', playerLoaded); - this.eventManager_.listen(this.player_, 'unloading', playerUnloading); - this.eventManager_.listen(this.player_, 'trackschanged', setupChapters); - this.eventManager_.listen(this.player_, 'metadata', (event) => { - const payload = event['payload']; - if (!payload) { - return; - } - let title; - if (payload['key'] == 'TIT2' && payload['data']) { - title = payload['data']; - } - let imageUrl; - if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') { - imageUrl = payload['data']; - } - if (title) { - setupTitle(title); - } - if (imageUrl) { - setupPoster(imageUrl); - } - }); - this.eventManager_.listen(this.player_, 'sessiondata', (event) => { - const id = event['id']; - switch (id) { - case 'com.apple.hls.title': { - const title = event['value']; - if (title) { - setupTitle(title); - } - break; - } - case 'com.apple.hls.poster': { - let imageUrl = event['value']; - if (imageUrl) { - imageUrl = imageUrl.replace('{w}', '512') - .replace('{h}', '512') - .replace('{f}', 'jpeg'); - setupPoster(imageUrl); - } - break; - } - } - }); - this.eventManager_.listen(this.player_, 'programinformation', (event) => { - if (!event['detail']) { - return; - } - const TXml = shaka.util.TXml; - /** @type {!shaka.extern.xml.Node} */ - const detail = /** @type {!shaka.extern.xml.Node} */(event['detail']); - const titleNode = TXml.findChild(detail, 'Title'); - if (titleNode) { - const title = TXml.getContents(titleNode); - if (title) { - setupTitle(title); - } - } - }); - - const checkQueueItems = () => { - const itemsLength = this.queueManager_.getItems().length; - const currentIndex = this.queueManager_.getCurrentItemIndex(); - if (itemsLength <= 1 || currentIndex == -1) { - addMediaSessionHandler('previoustrack', null); - addMediaSessionHandler('nexttrack', null); - return; - } - if (currentIndex > 0) { - addMediaSessionHandler('previoustrack', commonHandler); - } else { - addMediaSessionHandler('previoustrack', null); - } - if ((currentIndex + 1) < itemsLength) { - addMediaSessionHandler('nexttrack', commonHandler); - } else { - addMediaSessionHandler('nexttrack', null); - } - }; - - this.eventManager_.listen( - this.queueManager_, 'currentitemchanged', checkQueueItems); - this.eventManager_.listen( - this.queueManager_, 'itemsinserted', checkQueueItems); - this.eventManager_.listen( - this.queueManager_, 'itemsremoved', checkQueueItems); - this.eventManager_.listen(this.player_, 'loading', checkQueueItems); - - const checkSkipAd = () => { - if (!this.ad_ || !this.ad_.isSkippable() || !this.ad_.canSkipNow()) { - addMediaSessionHandler('skipad', null); - } else { - addMediaSessionHandler('skipad', commonHandler); - } - }; - - this.eventManager_.listen( - this.adManager_, shaka.ads.Utils.AD_STARTED, checkSkipAd); - this.eventManager_.listen( - this.adManager_, shaka.ads.Utils.AD_SKIP_STATE_CHANGED, checkSkipAd); - this.eventManager_.listen( - this.adManager_, shaka.ads.Utils.AD_STOPPED, checkSkipAd); - } - - - /** - * @private - */ - removeMediaSession_() { - if (!this.config_.setupMediaSession || !navigator.mediaSession) { - return; - } - try { - navigator.mediaSession.setPositionState(); - } catch (error) {} - - const disableMediaSessionHandler = (type) => { - try { - navigator.mediaSession.setActionHandler(type, null); - } catch (error) {} - }; - - disableMediaSessionHandler('pause'); - disableMediaSessionHandler('play'); - disableMediaSessionHandler('seekbackward'); - disableMediaSessionHandler('seekforward'); - disableMediaSessionHandler('seekto'); - disableMediaSessionHandler('stop'); - disableMediaSessionHandler('enterpictureinpicture'); - } - - /** * When a mobile device is rotated to landscape layout, and the video is * loaded, make the demo app go into fullscreen. @@ -2538,6 +2256,20 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.updateTimeAndSeekRange_(); } + /** + * @param {number} increment + */ + seekIncrement(increment) { + this.seek_(this.seekBar_.getValue() + increment); + } + + /** + * @param {number} value + */ + seekTo(value) { + this.seek_(value); + } + /** * Called when the seek range or current time need to be updated. * @private diff --git a/ui/externs/ui.js b/ui/externs/ui.js index 450567392..7a8f579da 100644 --- a/ui/externs/ui.js +++ b/ui/externs/ui.js @@ -173,6 +173,49 @@ shaka.extern.UIQualityMarks; */ shaka.extern.UIShortcuts; +/** + * @typedef {{ + * enabled: boolean, + * handleMetadata: boolean, + * handleActions: boolean, + * handlePosition: boolean, + * supportedActions: !Array, + * }} + * + * @property {boolean} enabled + * If true, MediaSession controls will be managed by the UI. + *
+ * Defaults to true. + * @property {boolean} handleMetadata + * Setup MediaSession metadata from the following sources: + *
+ * - ID3 with the `TIT2` tag for title + *
+ * - ID3 with the `APIC` tag for image + *
+ * - HLS with the `#EXT-X-SESSION-DATA` tag with the ID + * `com.apple.hls.title` for title + *
+ * - HLS with the `#EXT-X-SESSION-DATA` tag with the ID + * `com.apple.hls.poster` for image + *
+ * - DASH with `ProgramInformation` element and child `Title` field for title. + *
+ * Defaults to true. + * @property {boolean} handleActions + * If true, MediaSession actions supported will be managed by the UI. + *
+ * Defaults to true. + * @property {boolean} handlePosition + * If true, MediaSession position will be managed by the UI. + *
+ * Defaults to true. + * @property {!Array} supportedActions + * List of supported MediaSession actions. + * @exportDoc + */ +shaka.extern.UIMediaSession; + /** * @description * The UI's configuration options. @@ -217,7 +260,6 @@ shaka.extern.UIShortcuts; * refreshTickInSeconds: number, * displayInVrMode: boolean, * defaultVrProjectionMode: string, - * setupMediaSession: boolean, * preferVideoFullScreenInVisionOS: boolean, * showAudioCodec: boolean, * showVideoCodec: boolean, @@ -231,6 +273,7 @@ shaka.extern.UIShortcuts; * enableVrDeviceMotion: boolean, * showUIAlwaysOnAudioOnly: boolean, * preferIntlDisplayNames: boolean, + * mediaSession: shaka.extern.UIMediaSession, * }} * * @property {!Array} controlPanelElements @@ -435,12 +478,6 @@ shaka.extern.UIShortcuts; * 'halfequirectangular' or 'cubemap'. *
* Defaults to 'equirectangular'. - * @property {boolean} setupMediaSession - * If true, MediaSession controls will be managed by the UI. It will also use - * the ID3 APIC and TIT2 as image and title in Media Session, and ID3 APIC - * will also be used to change video poster. - *
- * Defaults to true. * @property {boolean} preferVideoFullScreenInVisionOS * If true, we will use the fullscreen API of the video element itself if it * is available in Vision OS. This is useful to be able to access 3D @@ -500,6 +537,8 @@ shaka.extern.UIShortcuts; * is available. *
* Defaults to true. + * @property {shaka.extern.UIMediaSession} mediaSession + * Media Session config. * @exportDoc */ shaka.extern.UIConfiguration; diff --git a/ui/media_session.js b/ui/media_session.js new file mode 100644 index 000000000..fe1a10aad --- /dev/null +++ b/ui/media_session.js @@ -0,0 +1,520 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.ui.MediaSession'); + +goog.require('shaka.log'); +goog.require('shaka.ads.Utils'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.IReleasable'); +goog.require('shaka.util.TXml'); + +goog.requireType('shaka.Player'); +goog.requireType('shaka.ui.Controls'); + +/** + * @export + * @implements {shaka.util.IReleasable} + */ +shaka.ui.MediaSession = class { + /** + * @param {!shaka.ui.Controls} controls + */ + constructor(controls) { + /** @private {!shaka.ui.Controls} */ + this.controls_ = controls; + + /** @private {shaka.Player} */ + this.player_ = this.controls_.getPlayer(); + + /** @private {HTMLMediaElement} */ + this.video_ = this.controls_.getVideo(); + + /** @private {shaka.extern.IAdManager} */ + this.adManager_ = this.controls_.getAdManager(); + + /** @private {shaka.extern.IQueueManager} */ + this.queueManager_ = this.controls_.getQueueManager(); + + /** @private {!shaka.extern.UIConfiguration} */ + this.config_ = this.controls_.getConfig(); + + /** @private {shaka.util.EventManager} */ + this.loadEventManager_ = new shaka.util.EventManager(); + + /** @private {shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @private {boolean} */ + this.supported_ = !!navigator.mediaSession; + + /** @private {boolean} */ + this.enabled_ = false; + + /** @private {boolean} */ + this.supportsChapterInfo_ = + // eslint-disable-next-line no-restricted-syntax + this.supported_ && 'chapterInfo' in MediaMetadata.prototype; + + /** @private {!Set} */ + this.actionsHandled_ = new Set(); + + if (this.player_.isFullyLoaded()) { + this.init_(); + } + this.loadEventManager_.listen(this.player_, 'loading', () => { + this.init_(); + }); + this.loadEventManager_.listen(this.player_, 'unloading', () => { + this.stop_(); + }); + } + + /** + * @param {!shaka.extern.UIConfiguration} config + * @export + */ + configure(config) { + this.stop_(); + this.config_ = config; + this.init_(); + } + + /** + * @override + * @export + */ + release() { + if (this.loadEventManager_) { + this.loadEventManager_.release(); + this.loadEventManager_ = null; + } + if (this.eventManager_) { + this.eventManager_.release(); + this.eventManager_ = null; + } + this.stop_(); + } + + /** + * @private + */ + stop_() { + if (!this.enabled_) { + return; + } + this.enabled_ = false; + if (this.eventManager_) { + this.eventManager_.removeAll(); + } + if (this.config_.mediaSession.handleMetadata) { + navigator.mediaSession.metadata = new MediaMetadata({}); + } + if (this.config_.mediaSession.handlePosition) { + this.clearPositionState_(); + } + for (const actionName of Array.from(this.actionsHandled_)) { + this.addMediaSessionHandler(actionName); + } + this.actionsHandled_.clear(); + } + + /** + * @private + */ + init_() { + if (!this.supported_ || this.enabled_ || + !this.config_.mediaSession.enabled) { + return; + } + this.enabled_ = true; + + this.setupMediaSessionActions_(); + this.setupMediaSessionMetadata_(); + this.setupMediaSessionPosition_(); + } + + /** + * @return {!{title: string, artist: string, album: string, + * artwork: Object, chapterInfo: ?Object}} + */ + getMediaMetadata() { + const metadata = { + title: '', + artist: '', + album: '', + artwork: [], + }; + if (this.supportsChapterInfo_) { + metadata.chapterInfo = []; + } + if (this.supported_ && navigator.mediaSession.metadata) { + metadata.title = navigator.mediaSession.metadata.title; + metadata.artist = navigator.mediaSession.metadata.artist; + metadata.album = navigator.mediaSession.metadata.album; + metadata.artwork = navigator.mediaSession.metadata.artwork; + if (this.supportsChapterInfo_) { + metadata.chapterInfo = navigator.mediaSession.metadata.chapterInfo; + } + } + return metadata; + } + + /** + * @param {string} title + * @export + */ + setupTitle(title) { + const castReceiver = this.controls_.getCastReceiver(); + if (castReceiver) { + castReceiver.setContentTitle(title); + } + if (this.supported_) { + const metadata = this.getMediaMetadata(); + metadata.title = title; + navigator.mediaSession.metadata = new MediaMetadata(metadata); + } + } + + /** + * @param {string} imageUrl + * @export + */ + setupPoster(imageUrl) { + const video = /** @type {HTMLVideoElement} */ (this.video_); + if (imageUrl != video.poster) { + video.poster = imageUrl; + } + const castReceiver = this.controls_.getCastReceiver(); + if (castReceiver) { + castReceiver.setContentImage(imageUrl); + } + if (this.supported_) { + const metadata = this.getMediaMetadata(); + metadata.artwork = [{src: imageUrl}]; + navigator.mediaSession.metadata = new MediaMetadata(metadata); + } + } + + /** + * @param {!Array} chapters + * @export + */ + setupChapters(chapters) { + if (!this.supportsChapterInfo_) { + return; + } + const chapterInfo = []; + for (const chapter of chapters) { + chapterInfo.push({ + title: chapter.title, + startTime: chapter.startTime, + artwork: [], + }); + } + const metadata = this.getMediaMetadata(); + metadata.chapterInfo = chapterInfo; + navigator.mediaSession.metadata = new MediaMetadata(metadata); + } + + /** + * @param {string} type + * @param {?Function=} callback + * @export + */ + addMediaSessionHandler(type, callback = null) { + if (!this.supported_) { + return; + } + try { + if (callback) { + if (!this.config_.mediaSession.supportedActions.includes(type)) { + return; + } + this.actionsHandled_.add(type); + } else { + if (!this.actionsHandled_.has(type)) { + return; + } + this.actionsHandled_.delete(type); + } + navigator.mediaSession.setActionHandler(type, callback); + } catch (error) { + shaka.log.debug( + `The "${type}" media session action is not supported.`); + } + } + + /** + * @param {!{action: string, seekOffset: ?number, + * seekTime: ?number}} details + * @export + */ + commonActionHandler(details) { + const ad = this.controls_.getAd(); + const keyboardSeekDistance = this.config_.keyboardSeekDistance; + switch (details.action) { + case 'pause': + this.controls_.playPausePresentation(); + break; + case 'play': + this.controls_.playPausePresentation(); + break; + case 'seekbackward': + if (details.seekOffset && !isFinite(details.seekOffset)) { + break; + } + if (!ad || !ad.isLinear()) { + this.controls_.seekIncrement( + -(details.seekOffset || keyboardSeekDistance)); + } + break; + case 'seekforward': + if (details.seekOffset && !isFinite(details.seekOffset)) { + break; + } + if (!ad || !ad.isLinear()) { + this.controls_.seekIncrement( + details.seekOffset || keyboardSeekDistance); + } + break; + case 'seekto': + if (details.seekTime && !isFinite(details.seekTime)) { + break; + } + if (!ad || !ad.isLinear()) { + this.controls_.seekTo( + this.player_.seekRange().start + details.seekTime); + } + break; + case 'stop': + this.player_.unload(); + break; + case 'enterpictureinpicture': + if (!ad || !ad.isLinear()) { + this.controls_.togglePiP(); + } + break; + case 'nexttrack': + this.queueManager_.playItem( + this.queueManager_.getCurrentItemIndex() + 1); + break; + case 'previoustrack': + this.queueManager_.playItem( + this.queueManager_.getCurrentItemIndex() - 1); + break; + case 'skipad': + if (ad) { + ad.skip(); + } + break; + } + }; + + /** + * @private + */ + clearPositionState_() { + try { + navigator.mediaSession.setPositionState(); + } catch (error) { + shaka.log.v2( + 'setPositionState in media session is not supported.'); + } + } + + /** + * @private + */ + setupMediaSessionMetadata_() { + if (!this.config_.mediaSession.handleMetadata) { + return; + } + this.eventManager_.listen(this.player_, 'trackschanged', () => { + this.setupChapters(this.controls_.getChapters()); + }); + this.eventManager_.listen(this.player_, 'metadata', (event) => { + const payload = event['payload']; + if (!payload) { + return; + } + let title; + if (payload['key'] == 'TIT2' && payload['data']) { + title = payload['data']; + } + let imageUrl; + if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') { + imageUrl = payload['data']; + } + if (title) { + this.setupTitle(title); + } + if (imageUrl) { + this.setupPoster(imageUrl); + } + }); + this.eventManager_.listen(this.player_, 'sessiondata', (event) => { + const id = event['id']; + switch (id) { + case 'com.apple.hls.title': { + const title = event['value']; + if (title) { + this.setupTitle(title); + } + break; + } + case 'com.apple.hls.poster': { + let imageUrl = event['value']; + if (imageUrl) { + imageUrl = imageUrl.replace('{w}', '512') + .replace('{h}', '512') + .replace('{f}', 'jpeg'); + this.setupPoster(imageUrl); + } + break; + } + } + }); + this.eventManager_.listen(this.player_, 'programinformation', (event) => { + if (!event['detail']) { + return; + } + const TXml = shaka.util.TXml; + /** @type {!shaka.extern.xml.Node} */ + const detail = + /** @type {!shaka.extern.xml.Node} */(event['detail']); + const titleNode = TXml.findChild(detail, 'Title'); + if (titleNode) { + const title = TXml.getContents(titleNode); + if (title) { + this.setupTitle(title); + } + } + }); + this.eventManager_.listen(this.player_, 'unloading', () => { + navigator.mediaSession.metadata = new MediaMetadata({}); + }); + } + + /** + * @private + */ + setupMediaSessionPosition_() { + if (!this.config_.mediaSession.handlePosition) { + return; + } + const updatePositionState = () => { + const ad = this.controls_.getAd(); + if (ad && ad.isLinear()) { + this.clearPositionState_(); + return; + } + const seekRange = this.player_.seekRange(); + let duration = seekRange.end - seekRange.start; + const position = parseFloat( + (this.video_.currentTime - seekRange.start).toFixed(2)); + if (this.player_.isLive() && Math.abs(duration - position) < 1) { + // Positive infinity indicates media without a defined end, such as a + // live stream. + duration = Infinity; + } + try { + navigator.mediaSession.setPositionState({ + duration: Math.max(0, duration), + playbackRate: this.video_.playbackRate, + position: Math.max(0, position), + }); + } catch (error) { + shaka.log.v2( + 'setPositionState in media session is not supported.'); + } + }; + const playerLoaded = () => { + if (this.player_.isLive() || this.player_.seekRange().start != 0) { + updatePositionState(); + this.eventManager_.listen( + this.video_, 'timeupdate', updatePositionState); + } else { + this.clearPositionState_(); + } + }; + if (this.player_.isFullyLoaded()) { + playerLoaded(); + } + this.eventManager_.listen( + this.player_, 'loaded', playerLoaded); + this.eventManager_.listen(this.player_, 'unloading', () => { + this.eventManager_.unlisten( + this.video_, 'timeupdate', updatePositionState); + }); + } + + /** @private */ + setupMediaSessionActions_() { + if (!this.config_.mediaSession.handleActions) { + return; + } + const actionHandler = (details) => { + this.commonActionHandler(details); + }; + this.addMediaSessionHandler('pause', actionHandler); + this.addMediaSessionHandler('play', actionHandler); + this.addMediaSessionHandler('seekbackward', actionHandler); + this.addMediaSessionHandler('seekforward', actionHandler); + this.addMediaSessionHandler('seekto', actionHandler); + this.addMediaSessionHandler('stop', actionHandler); + this.addMediaSessionHandler('enterpictureinpicture', actionHandler); + + const checkQueueItems = () => { + const itemsLength = this.queueManager_.getItems().length; + const currentIndex = this.queueManager_.getCurrentItemIndex(); + if (itemsLength <= 1 || currentIndex == -1) { + this.addMediaSessionHandler('previoustrack', null); + this.addMediaSessionHandler('nexttrack', null); + return; + } + if (currentIndex > 0) { + this.addMediaSessionHandler('previoustrack', actionHandler); + } else { + this.addMediaSessionHandler('previoustrack', null); + } + if ((currentIndex + 1) < itemsLength) { + this.addMediaSessionHandler('nexttrack', actionHandler); + } else { + this.addMediaSessionHandler('nexttrack', null); + } + }; + + this.eventManager_.listen( + this.queueManager_, 'currentitemchanged', checkQueueItems); + this.eventManager_.listen( + this.queueManager_, 'itemsinserted', checkQueueItems); + this.eventManager_.listen( + this.queueManager_, 'itemsremoved', checkQueueItems); + this.eventManager_.listen( + this.player_, 'loading', checkQueueItems); + + checkQueueItems(); + + const checkSkipAd = () => { + const ad = this.controls_.getAd(); + if (!ad || !ad.isSkippable() || !ad.canSkipNow()) { + this.addMediaSessionHandler('skipad', null); + } else { + this.addMediaSessionHandler('skipad', actionHandler); + } + }; + + this.eventManager_.listen( + this.adManager_, shaka.ads.Utils.AD_STARTED, checkSkipAd); + this.eventManager_.listen( + this.adManager_, shaka.ads.Utils.AD_SKIP_STATE_CHANGED, checkSkipAd); + this.eventManager_.listen( + this.adManager_, shaka.ads.Utils.AD_STOPPED, checkSkipAd); + + checkSkipAd(); + } +}; diff --git a/ui/skip_next_button.js b/ui/skip_next_button.js index b6b03fc0e..ad3093a24 100644 --- a/ui/skip_next_button.js +++ b/ui/skip_next_button.js @@ -30,7 +30,7 @@ shaka.ui.SkipNextButton = class extends shaka.ui.Element { constructor(parent, controls) { super(parent, controls); - this.queueManager_ = this.player.getQueueManager(); + this.queueManager_ = this.controls.getQueueManager(); if (!this.queueManager_) { return; diff --git a/ui/skip_previous_button.js b/ui/skip_previous_button.js index f48474f28..0d76327f9 100644 --- a/ui/skip_previous_button.js +++ b/ui/skip_previous_button.js @@ -30,7 +30,7 @@ shaka.ui.SkipPreviousButton = class extends shaka.ui.Element { constructor(parent, controls) { super(parent, controls); - this.queueManager_ = this.player.getQueueManager(); + this.queueManager_ = this.controls.getQueueManager(); if (!this.queueManager_) { return; diff --git a/ui/ui.js b/ui/ui.js index 14fa1bd2f..b34f3a96b 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -286,6 +286,22 @@ shaka.ui.Overlay = class { } controlPanelElements.push('fullscreen'); + const mediaSessionActions = [ + 'pause', + 'play', + 'seekbackward', + 'seekforward', + 'seekto', + 'stop', + 'skipad', + 'previoustrack', + 'nexttrack', + ]; + if ('documentPictureInPicture' in window || + document.pictureInPictureEnabled) { + mediaSessionActions.push('enterpictureinpicture'); + } + const config = { controlPanelElements, topControlPanelElements: [ @@ -392,7 +408,6 @@ shaka.ui.Overlay = class { refreshTickInSeconds: 0.125, displayInVrMode: false, defaultVrProjectionMode: 'equirectangular', - setupMediaSession: true, preferVideoFullScreenInVisionOS: true, showAudioCodec: true, showVideoCodec: true, @@ -423,6 +438,13 @@ shaka.ui.Overlay = class { enableVrDeviceMotion: true, showUIAlwaysOnAudioOnly: true, preferIntlDisplayNames: true, + mediaSession: { + enabled: true, + handleMetadata: true, + handleActions: true, + handlePosition: true, + supportedActions: mediaSessionActions, + }, }; // On mobile, by default, hide the volume slide and the small play/pause