mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-14 15:56:38 +03:00
feat(UI)!: Improve Media Session management (#9483)
Fixes https://github.com/shaka-project/shaka-player/issues/9478
This commit is contained in:
committed by
GitHub
parent
b1e276cfb8
commit
ff72abc4f5
@@ -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
|
||||
|
||||
@@ -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_();
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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]);
|
||||
}
|
||||
|
||||
Vendored
+62
-330
@@ -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
|
||||
|
||||
+46
-7
@@ -173,6 +173,49 @@ shaka.extern.UIQualityMarks;
|
||||
*/
|
||||
shaka.extern.UIShortcuts;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* enabled: boolean,
|
||||
* handleMetadata: boolean,
|
||||
* handleActions: boolean,
|
||||
* handlePosition: boolean,
|
||||
* supportedActions: !Array<string>,
|
||||
* }}
|
||||
*
|
||||
* @property {boolean} enabled
|
||||
* If true, MediaSession controls will be managed by the UI.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @property {boolean} handleMetadata
|
||||
* Setup MediaSession metadata from the following sources:
|
||||
* <br>
|
||||
* - ID3 with the `TIT2` tag for title
|
||||
* <br>
|
||||
* - ID3 with the `APIC` tag for image
|
||||
* <br>
|
||||
* - HLS with the `#EXT-X-SESSION-DATA` tag with the ID
|
||||
* `com.apple.hls.title` for title
|
||||
* <br>
|
||||
* - HLS with the `#EXT-X-SESSION-DATA` tag with the ID
|
||||
* `com.apple.hls.poster` for image
|
||||
* <br>
|
||||
* - DASH with `ProgramInformation` element and child `Title` field for title.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @property {boolean} handleActions
|
||||
* If true, MediaSession actions supported will be managed by the UI.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @property {boolean} handlePosition
|
||||
* If true, MediaSession position will be managed by the UI.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @property {!Array<string>} 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<string>} controlPanelElements
|
||||
@@ -435,12 +478,6 @@ shaka.extern.UIShortcuts;
|
||||
* <code>'halfequirectangular'</code> or <code>'cubemap'</code>.
|
||||
* <br>
|
||||
* Defaults to <code>'equirectangular'</code>.
|
||||
* @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.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @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.
|
||||
* <br>
|
||||
* Defaults to <code>true</code>.
|
||||
* @property {shaka.extern.UIMediaSession} mediaSession
|
||||
* Media Session config.
|
||||
* @exportDoc
|
||||
*/
|
||||
shaka.extern.UIConfiguration;
|
||||
|
||||
@@ -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<string>} */
|
||||
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<!shaka.extern.Chapter>} 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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user