import DomSmith from '../../lib/dom/DomSmith.js';
import { isObject } from '../../lib/util/object.js';
let cast;
/**
* The ChromeCast component for the Media Player enables casting of media content to Chromecast devices.
* You can control the video either using the standard controls on the player UI or via the ChromeCast remote.
* Supports Subtitles, as well as poster images & more on devices that support those features.
* @exports module:src/casting/ChromeCast
* @requires lib/util/object
* @requires lib/dom/DomSmith
* @author Frank Kudermann - alphanull
* @author Frank
* @version 1.0.0
* @license MIT
*/
export default class ChromeCast {
/**
* Configuration for the ChromeCast component.
* @type {Object}
* @property {boolean} [showControllerButton=true] If `true`, a controller button is displayed.
* @property {boolean} [showMenuButton=true] If `true`, a button in the settings menu (if available) is displayed.
* @property {boolean} [lazyLoadLib=true] If `true`, the cast Library is only loaded after user interaction.
*/
#config = {
showControllerButton: true,
showMenuButton: false,
lazyLoadLib: true
};
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Reference to the settings menu element.
* @type {module:src/ui/Popup}
*/
#parent;
/**
* Holds tokens of subscriptions to player events, for later unsubscribe.
* @type {number[]}
*/
#subscriptions;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* DomSmith instance for the Cast backdrop.
* @type {module:lib/dom/DomSmith}
*/
#backdrop;
/**
* DomSmith instance for the menu button.
* @type {module:lib/dom/DomSmith}
*/
#buttonMenu;
/**
* DomSmith instance for the controller button.
* @type {module:lib/dom/DomSmith}
*/
#buttonController;
/**
* Status information of the remote cast device.
* @type {Object}
*/
#remote = {
state: '',
duration: 0,
currentTime: 0,
connected: false,
paused: true,
activeTextTrack: 0,
seekTo: null
};
/**
* Local copy of the player's functions for restoring after casting.
* @type {Object}
* @property {Function} seek Original media.seek function of the player.
* @property {Function} play Original media.play function of the player.
* @property {Function} pause Original media.pause function of the player.
* @property {Function} volume Original media.volume function of the player.
* @property {Function} mute Original media.mute function of the player.
* @property {Function} load Original media.load function of the player.
* @property {string} lastSrc The last set media source.
*/
#local;
/**
* Instance of the Cast context.
* @type {Object}
*/
#castContext;
/**
* RemotePlayer instance for controlling the Cast device.
* @type {Object}
*/
#castPlayer;
/**
* RemotePlayerController for monitoring changes to the RemotePlayer.
* @type {Object}
*/
#castPlayerController;
/**
* Cast session instance.
*/
#castSession;
/**
* Time snapshot used for resuming playback.
* @type {number}
*/
#savedCurrentTime;
/**
* Volume state snapshot before casting.
* @type {number}
*/
#volumeState = -1;
/**
* Flag indicating if currently active media is supported for casting.
* @type {boolean}
*/
#isSupported;
/**
* Creates an instance of the ChromeCast component.
* @param {module:src/core/Player} player Reference to the main VisionPlayer instance.
* @param {module:src/controller/Controller} parent Reference to the parent instance.
* @param {Object} [options] Additional options.
* @param {symbol} [options.apiKey] Token for extended access to the player API.
*/
constructor(player, parent, { apiKey }) {
this.#config = player.initConfig('chromeCast', this.#config);
if (!this.#config || !window.chrome || this.#config.showControllerButton === false && this.#config.showMenuButton === false) return [false];
this.#player = player;
this.#parent = this.#player.getComponent('ui.controller.popupSettings', apiKey);
this.#apiKey = apiKey;
this.#backdrop = new DomSmith({
_ref: 'wrapper',
className: 'vip-chromecast',
'data-sort': 60,
_nodes: [{
_ref: 'bg',
className: 'vip-chromecast-bg',
ariaHidden: true,
_nodes: [{
_tag: 'p',
_nodes: [
this.#player.locale.t('chromecast.connected'),
{ _tag: 'br' },
{
_ref: 'device',
_text: this.#player.locale.t('chromecast.device')
}
]
}]
}, {
_tag: 'menu',
className: 'menu is-grouped',
_nodes: [{
_tag: 'button',
_ref: 'play',
ariaHidden: true,
tabIndex: -1,
click: this.#onTogglePlay,
_nodes: [{
_ref: 'buttonText',
_text: this.#player.locale.t('chromecast.play')
}]
}, {
_tag: 'button',
_ref: 'cancel',
ariaHidden: true,
tabIndex: -1,
click: this.#stopCasting,
_nodes: [this.#player.locale.t('chromecast.cancel')]
}]
}]
}, this.#player.dom.getElement(apiKey));
if (this.#parent && (this.#config === true || this.#config.showMenuButton !== false)) {
const id = this.#player.getConfig('player.id');
this.#buttonMenu = new DomSmith({
_tag: 'label',
for: `chromecast-control-${id}`,
click: e => {
e.preventDefault(); this.#toggleCasting();
},
_nodes: [{
_ref: 'label',
_tag: 'span',
className: 'form-label-text',
_nodes: this.#player.locale.t('chromecast.available')
}, {
_ref: 'input',
_tag: 'input',
id: `chromecast-control-${id}`,
name: `chromecast-control-${id}`,
type: 'checkbox',
className: 'is-toggle'
}]
}, this.#parent.getElement('top'));
}
if (this.#config === true || !this.#config.showControllerButton === false) {
this.#buttonController = new DomSmith({
_tag: 'button',
_ref: 'button',
className: 'icon chromecast',
'data-sort': 52,
ariaLabel: this.#player.locale.t('chromecast.chromecast'),
click: this.#toggleCasting,
$tooltip: { player, text: this.#player.locale.t('chromecast.chromecast') }
}, this.#player.getComponent('ui.controller', apiKey).getElement('right'));
}
this.#subscriptions = [
['subtitles/selected', this.#onSubtitleChange],
['subtitles/fontsize', this.#onFontChange],
['data/nomedia', this.#disable],
['media/ratechange', this.#onPlaybackRateChange],
['media/ready', this.#onMediaReady],
['media/error', this.#disable],
['media/canplay', this.#enable]
].map(([event, handler]) => this.#player.subscribe(event, handler));
// Also check session storage if we have any active session to resume
if (!this.#config.lazyLoadLib || sessionStorage.getItem('vip-chrome-cast-active')) this.#addScripts();
}
/**
* Load Cast API Scripts from Google. This is only done after the user clicks the cast button.
*/
#addScripts() {
const script = document.createElement('script');
script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
document.head.appendChild(script);
window.__onGCastApiAvailable = isAvailable => { if (isAvailable) this.#onAvailable(); };
}
/**
* Initializes the Cast context and sets up necessary event listeners.
*/
#onAvailable = () => {
cast = window.chrome.cast; // eslint-disable-line prefer-destructuring
this.#castContext = window.cast.framework.CastContext.getInstance();
this.#castContext.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED, this.#onSessionEvent);
this.#castContext.setOptions({
receiverApplicationId: cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: cast.AutoJoinPolicy.ORIGIN_SCOPED
});
this.#castPlayer = new window.cast.framework.RemotePlayer();
this.#castPlayerController = new window.cast.framework.RemotePlayerController(this.#castPlayer);
this.#castPlayerController.addEventListener(window.cast.framework.RemotePlayerEventType.ANY_CHANGE, this.#onCastEvent);
if (this.#config.lazyLoadLib) this.#toggleCasting();
};
/**
* Checks if the current media source is supported for casting and enables or disables buttons accordingly.
* @param {string} src The current media source.
* @listens module:src/core/Media#media/ready
*/
#onMediaReady = ({ src }) => {
const allowed = ['mp2t', 'mp4', 'ogg', 'wav', 'webm', 'apng', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'],
ext = src.split(/[#?]/)[0].split('.').pop().trim().toLowerCase();
this.#isSupported = allowed.includes(ext);
this.#enable();
};
/**
* Toggles casting on or off based on the current status.
* In addition the google cast code is loaded if invoked for the first time.
*/
#toggleCasting = () => {
if (!window.chrome.cast) this.#addScripts(); // Load Cast API if not already available
else if (this.#remote.connected) this.#stopCasting();
else this.#startCasting();
};
/**
* Toggles playback (play/pause).
*/
#onTogglePlay = () => {
if (this.#player.getState('media.paused')) {
this.#player.media.play();
this.#backdrop.buttonText.nodeValue = this.#player.locale.t('chromecast.pause');
} else {
this.#player.media.pause();
this.#backdrop.buttonText.nodeValue = this.#player.locale.t('chromecast.play');
}
};
/**
* Starts casting to a Chromecast device.
* @returns {void} Returns when cast session has failed.
* @fires module:src/ui/Notifications#notification
*/
async #startCasting() {
this.#volumeState = this.#volumeState > 0 ? this.#volumeState : this.#player.getState('media.volume');
if (this.#buttonMenu) this.#buttonMenu.label.nodeValue = this.#player.locale.t('chromecast.choose');
try {
this.#castSession = this.#castContext.getCurrentSession();
if (!this.#castSession) {
try {
return await this.#castContext.requestSession(); // Initiated from button
} catch (e) { // eslint-disable-line no-unused-vars
// user cancelled selection
this.#stopCasting();
this.#remote.connected = false;
sessionStorage.removeItem('vip-chrome-cast-active');
return;
}
}
this.#remote.connected = true;
if (this.#remote.sessionState !== 'SESSION_RESUMED') {
this.#remote.seekTo = this.#savedCurrentTime;
const request = this.#generateRequest();
await this.#castSession.loadMedia(request);
}
} catch (error) {
if (error === 'cancel') return;
this.#player.publish('notification', {
type: 'error',
title: 'ChromeCast',
message: this.#player.locale.t('chromecast.castError'),
messageSecondary: error,
options: { timeout: 6 }
}, this.#apiKey);
this.#player.media.volume(this.#volumeState);
this.#remote.error = error;
if (this.#buttonMenu) this.#buttonMenu.text.nodeValue = this.#player.locale.t('chromecast.available');
}
}
/**
* Generates a media load request for casting. Also tries to include title, poster image and subtitles, if available.
* @param {Object} [source] The current media source.
* @returns {Object} The generated load request.
*/
#generateRequest(source = {}) {
const { title = '', titleSecondary = '', poster, overlays = [], text = [] } = this.#player.data.getMediaData(),
{ src: currentSrc = this.#player.media.getElement(this.#apiKey).src, mimeType, encodings } = source,
aV1Type = /^video\/mp4;\s*codecs=av01/i.test(mimeType);
let src = currentSrc;
if (aV1Type && encodings) {
// cant play av1 on most cast reveivcers, try to find alternate encoding
const found = encodings.find(encoding => encoding && !/^video\/mp4;\s*codecs=av01/i.test(encoding.mimeType) && this.#player.media.canPlay(encoding));
if (found) ({ src } = found);
}
const mediaInfo = new cast.media.MediaInfo(src, mimeType),
lang = this.#player.getConfig('locale.lang'),
titleTranslated = isObject(title) ? title[lang] || title[Object.keys(title)[0]] : title,
titleSecondaryTranslated = isObject(titleSecondary) ? titleSecondary[lang] || titleSecondary[Object.keys(titleSecondary)[0]] : titleSecondary;
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.title = titleTranslated;
mediaInfo.metadata.subtitle = titleSecondaryTranslated;
if (poster) {
mediaInfo.metadata.images = [new cast.Image(poster)];
} else if (overlays.length) { // Search for poster image
const findPoster = overlays.find(({ type }) => type === 'poster');
if (findPoster && findPoster.src) mediaInfo.metadata.images = [new cast.Image(findPoster.src)];
}
if (text.length) {
const subtitles = text.filter(({ type }) => type === 'subtitles' || type === 'captions');
if (subtitles) {
mediaInfo.tracks = subtitles.map((subtitle, index) => {
const track = new cast.media.Track(index, 'TEXT');
track.name = subtitle.language;
track.subtype = subtitle.type;
track.trackContentId = subtitle.src;
track.trackContentType = 'text/vtt';
track.trackId = parseInt(index, 10); // This bug made me question life for a while
return track;
});
mediaInfo.textTrackStyle = new cast.media.TextTrackStyle();
mediaInfo.textTrackStyle.backgroundColor = '#11111166';
mediaInfo.textTrackStyle.edgeColor = '#00000040';
mediaInfo.textTrackStyle.edgeType = 'DROP_SHADOW';
mediaInfo.textTrackStyle.fontFamily = 'SANS_SERIF';
mediaInfo.textTrackStyle.fontScale = this.#remote.fontSize === 'small' ? 0.6 : this.#remote.fontSize === 'big' ? 1.1 : 0.8;
mediaInfo.textTrackStyle.foregroundColor = '#FFFFFF';
}
}
const request = new cast.media.LoadRequest(mediaInfo),
activeTrack = this.#remote.activeTextTrack || this.#player.getState('media.activeTextTrack');
request.activeTrackIds = activeTrack > -1 ? [activeTrack] : [];
request.autoplay = true;
request.playbackRate = this.#player.getState('media.playbackRate');
return request;
}
/**
* Switches the player's state and binds remote functions.
*/
#switchState() {
this.#savedCurrentTime = this.#player.getState('media.currentTime');
this.#volumeState = this.#player.getState('media.volume');
this.#player.setState('media.paused', { get: () => this.#castPlayer.isPaused }, this.#apiKey);
this.#player.setState('media.currentTime', { get: () => this.#castPlayer.currentTime }, this.#apiKey);
this.#player.setState('media.volume', { get: () => this.#castPlayer.volumeLevel }, this.#apiKey);
this.#local = {
api: {
seek: this.#player.media.seek,
play: this.#player.media.play,
pause: this.#player.media.pause,
volume: this.#player.media.volume,
mute: this.#player.media.mute,
load: this.#player.media.load
},
lastSrc: ''
};
// override player API
this.#player.media.seek = val => {
this.#castPlayer.currentTime = val;
this.#castPlayerController.seek();
};
this.#player.media.play = () => {
if (!this.#player.getState('media.paused')) return;
this.#castPlayerController.playOrPause();
this.#player.publish('media/play', this.#apiKey);
};
this.#player.media.pause = () => {
if (this.#player.getState('media.paused')) return;
this.#castPlayerController.playOrPause();
this.#player.publish('media/pause', this.#apiKey);
};
this.#player.media.volume = val => {
this.#castPlayer.volumeLevel = val;
this.#castPlayerController.setVolumeLevel();
this.#player.publish('media/volumechange', this.#apiKey);
};
this.#player.media.mute = val => {
this.#castPlayerController.muteOrUnmute();
this.#local.mute(val);
};
this.#player.media.load = async source => {
try {
const currentTime = this.#player.getState('media.currentTime'),
request = this.#generateRequest(source);
this.#remote.noIdleEvent = true;
await this.#castContext.getCurrentSession().loadMedia(request);
this.#local.lastSrc = source.src;
this.#remote.noIdleEvent = false;
this.#player.media.seek(currentTime);
} catch { }
};
}
/**
* Stops casting and restores the player to its original state.
* @param {boolean} [endCastSession] Indicates whether to also end the cast session.
*/
#stopCasting = (endCastSession = true) => {
this.#remote.connected = false;
this.#castPlayerController.stop();
this.#castContext.endCurrentSession(endCastSession);
// Needs reinitialization for unknown reasons
this.#castPlayer = new window.cast.framework.RemotePlayer();
this.#castPlayerController.removeEventListener(window.cast.framework.RemotePlayerEventType.ANY_CHANGE, this.#onCastEvent);
this.#castPlayerController = new window.cast.framework.RemotePlayerController(this.#castPlayer);
this.#castPlayerController.addEventListener(window.cast.framework.RemotePlayerEventType.ANY_CHANGE, this.#onCastEvent);
};
/**
* Handles the stopping of casting.
* @fires module:src/core/Media#media/volumechange
* @fires module:src/casting/ChromeCast#chromecast/stop
*/
async #castStopped() {
this.#player.publish('chromecast/stop', this.#apiKey);
if (this.#remote.error) {
// Do not restore if the connection was closed due to an error
this.#remote.error = '';
return;
}
// Restore old player functions
Object.entries(this.#local.api).forEach(([key, value]) => {
this.#player.media[key] = value;
});
// TODO: better encapsulation
this.#player.getComponent('media', this.#apiKey).setupState();
this.#backdrop.play.setAttribute('tabindex', '-1');
this.#backdrop.play.setAttribute('aria-hidden', 'true');
this.#backdrop.cancel.setAttribute('tabindex', '-1');
this.#backdrop.cancel.setAttribute('aria-hidden', 'true');
this.#backdrop.bg.setAttribute('aria-hidden', 'true');
// Restore player state
if (this.#local.lastSrc) await this.#player.media.load({ src: this.#local.lastSrc });
this.#player.media.seek(this.#remote.currentTime);
if (!this.#remote.paused) this.#player.media.play();
this.#player.media.volume(this.#volumeState);
this.#player.publish('media/volumechange', this.#apiKey);
this.#volumeState = -1;
this.#player.dom.getElement(this.#apiKey).classList.remove('is-casting');
if (this.#buttonMenu) this.#buttonMenu.input.checked = false;
}
/**
* Handles cast events.
* @param {Object} event The cast event from the google lib.
* @param {string} event.field The field that changed.
* @param {*} event.value The new value of the field.
* @fires module:src/casting/ChromeCast#chromecast/start
* @fires module:src/core/Media#media/volumechange
* @fires module:src/core/Media#media/timeupdate
* @fires module:src/core/Media#media/pause
* @fires module:src/core/Media#media/play
*/
#onCastEvent = async({ field, value }) => {
// if (field !== 'displayStatus') console.debug('onCastEvent: ', field, value);
switch (field) {
case 'isConnected':
if (value) {
if (this.#parent) this.#parent.hidePopup();
this.#player.dom.getElement(this.#apiKey).classList.add('is-casting');
this.#player.publish('chromecast/start', this.#apiKey);
this.#player.media.pause();
this.#switchState();
const session = this.#castContext.getCurrentSession();
this.#backdrop.device.nodeValue = session.getCastDevice().friendlyName || this.#player.locale.t('chromecast.device');
this.#backdrop.play.removeAttribute('tabindex');
this.#backdrop.play.removeAttribute('aria-hidden');
this.#backdrop.cancel.removeAttribute('tabindex');
this.#backdrop.cancel.removeAttribute('aria-hidden');
this.#backdrop.bg.removeAttribute('aria-hidden');
if (this.#buttonMenu) this.#buttonMenu.input.checked = true;
sessionStorage.setItem('vip-chrome-cast-active', 'true');
this.#startCasting();
} else {
sessionStorage.removeItem('vip-chrome-cast-active');
this.#castStopped();
}
break;
case 'canSeek':
if (value && this.#remote.seekTo !== null) {
this.#castPlayer.currentTime = this.#remote.seekTo;
this.#castPlayerController.seek();
this.#remote.seekTo = null;
}
break;
case 'volumechange':
case 'volumeLevel':
this.#player.media.volume(value);
this.#player.publish('media/volumechange', this.#apiKey);
break;
case 'isMuted':
this.#local.mute(value);
break;
case 'duration':
if (this.#remote.sessionState !== 'SESSION_ENDED') this.#remote.duration = value;
break;
case 'currentTime':
if (this.#remote.sessionState === 'SESSION_ENDED') break;
this.#remote.currentTime = value;
this.#player.publish('media/timeupdate', this.#apiKey);
break;
case 'playerState':
this.#remote.state = value;
switch (value) {
case 'PAUSED':
this.#remote.paused = true;
this.#backdrop.bg.classList.remove('is-buffering');
this.#backdrop.buttonText.nodeValue = this.#player.locale.t('chromecast.play');
this.#player.publish('media/pause', this.#apiKey);
break;
case 'PLAYING':
this.#remote.paused = false;
this.#backdrop.bg.classList.remove('is-buffering');
this.#backdrop.buttonText.nodeValue = this.#player.locale.t('chromecast.pause');
this.#player.publish('media/play', this.#apiKey);
break;
case 'BUFFERING':
this.#backdrop.bg.classList.add('is-buffering');
break;
case 'IDLE':
if (this.#remote.connected && !this.#remote.noIdleEvent) {
if (this.#player.getState('media.loop')) {
const request = this.#generateRequest(),
session = this.#castContext.getCurrentSession();
await session.loadMedia(request);
} else {
this.#player.publish('media/pause', this.#apiKey);
this.#remote.paused = true;
this.#stopCasting();
}
}
break;
default:
}
break;
case 'savedPlayerState':
if (value) this.#remote.currentTime = value.currentTime;
break;
default:
}
};
/**
* Handles session events from the Cast context.
* @param {Object} event Session event from the google lib.
*/
#onSessionEvent = event => {
switch (event.sessionState) {
case window.cast.framework.SessionState.SESSION_STARTED:
this.#remote.sessionState = 'SESSION_STARTED';
break;
case window.cast.framework.SessionState.SESSION_RESUMED:
this.#remote.sessionState = 'SESSION_RESUMED';
break;
case window.cast.framework.SessionState.SESSION_ENDED:
this.#remote.sessionState = 'SESSION_ENDED';
break;
default:
}
};
/**
* Handles changes to subtitles.
* @param {Object} event Subtitle change event info.
* @param {number} event.index Index of the selected subtitle track.
* @listens module:src/text/Subtitles#subtitles/selected
*/
#onSubtitleChange = ({ index }) => {
this.#remote.activeTextTrack = index;
if (!cast) return;
const activeTrackId = index > -1 ? [index] : [],
request = new cast.media.EditTracksInfoRequest(activeTrackId),
session = this.#castContext.getCurrentSession(),
media = session ? session.getMediaSession() : null;
if (media) media.editTracksInfo(request);
};
/**
* Handles changes to the subtitle font size.
* @param {string} size The new font size ('small', 'normal', 'big').
* @listens module:src/text/Subtitles#subtitles/fontsize
*/
#onFontChange = ({ fontSize }) => {
this.#remote.fontSize = fontSize;
if (!cast) return;
const session = this.#castContext.getCurrentSession(),
media = session ? session.getMediaSession() : null;
if (!media) return;
const fontScale = fontSize === 'small' ? 0.6 : this.#remote.fontSize === 'big' ? 1.1 : 0.8,
request = new cast.media.EditTracksInfoRequest(null, { fontScale });
media.editTracksInfo(request);
};
/**
* Handles changes to the playback rate.
* @listens module:src/core/Media#media/ratechange
*/
#onPlaybackRateChange = () => {
if (!cast) return;
const session = this.#castContext.getCurrentSession(),
media = session ? session.getMediaSession() : null;
if (media) {
// https://stackoverflow.com/questions/70205119/setting-playbackrate-from-a-chromecast-websender
session.sendMessage('urn:x-cast:com.google.cast.media', {
type: 'SET_PLAYBACK_RATE',
playbackRate: this.#player.getState('media.playbackRate'),
mediaSessionId: media.mediaSessionId,
requestId: 0
});
}
};
/**
* Enables the play button functionality. This method listens to canplay events in order to restore a usable state again
* when the player recovered from a media error (for example by loading another file).
* @listens module:src/core/Media#media/canplay
*/
#enable = () => {
if (this.#isSupported) {
if (this.#buttonController) this.#buttonController.button.style.display = 'block';
this.#buttonMenu?.mount();
} else {
this.#disable();
}
};
/**
* Disables the button functionality. This method listens to media error events which cause the button to be disabled.
* @listens module:src/core/Media#media/error
* @listens module:src/core/Data#data/nomedia
*/
#disable = () => {
if (this.#buttonController) this.#buttonController.button.style.display = 'none';
this.#buttonMenu?.unmount();
};
/**
* Removes all events, subscriptions, and DOM nodes created by this component.
*/
destroy() {
if (this.#castContext) {
this.#castContext.removeEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED, this.#onSessionEvent);
this.#castPlayerController.removeEventListener(window.cast.framework.RemotePlayerEventType.ANY_CHANGE, this.#onCastEvent);
}
window.__onGCastApiAvailable = null;
this.#buttonController?.destroy();
this.#buttonMenu?.destroy();
this.#backdrop.destroy();
this.#player.unsubscribe(this.#subscriptions);
this.#player = this.#parent = this.#backdrop = this.#buttonController = this.#buttonMenu = null;
this.#castContext = this.#castPlayer = this.#castSession = this.#apiKey = null;
}
}
/**
* This event is fired when chromecast was started.
* @event module:src/casting/ChromeCast#chromecast/start
*/
/**
* This event is fired when chromecast was stopped.
* @event module:src/casting/ChromeCast#chromecast/stop
*/