/* global chrome */ import { RemotePlayback } from './castable-remote-playback.js'; import { privateProps, requiresCastFramework, loadCastFramework, currentSession, getDefaultCastOptions, isHls, getPlaylistSegmentFormat } from './castable-utils.js'; /** * CastableMediaMixin * * This mixin function provides a way to compose multiple classes. * @see https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ * * @param {HTMLMediaElement} superclass - HTMLMediaElement or an extended class of it. * @return {CastableMedia} */ export const CastableMediaMixin = (superclass) => class CastableMedia extends superclass { static observedAttributes = [ ...(superclass.observedAttributes ?? []), 'cast-src', 'cast-content-type', 'cast-stream-type', 'cast-receiver', ]; #localState = { paused: false }; #castOptions = getDefaultCastOptions(); #castCustomData; #remote; get remote() { if (this.#remote) return this.#remote; if (requiresCastFramework()) { // No need to load the Cast framework if it's disabled. if (!this.disableRemotePlayback) { loadCastFramework(); } privateProps.set(this, { loadOnPrompt: () => this.#loadOnPrompt() }); return (this.#remote = new RemotePlayback(this)); } return super.remote; } get #castPlayer() { return privateProps.get(this.remote)?.getCastPlayer?.(); } attributeChangedCallback(attrName, oldValue, newValue) { super.attributeChangedCallback(attrName, oldValue, newValue); if (attrName === 'cast-receiver' && newValue) { this.#castOptions.receiverApplicationId = newValue; return; } if (!this.#castPlayer) return; switch (attrName) { case 'cast-stream-type': case 'cast-src': this.load(); break; } } async #loadOnPrompt() { // Pause locally when the session is created. this.#localState.paused = super.paused; super.pause(); // Sync over the muted state but not volume, 100% is different on TV's :P this.muted = super.muted; try { await this.load(); } catch (err) { console.error(err); } } async load() { if (!this.#castPlayer) return super.load(); const mediaInfo = new chrome.cast.media.MediaInfo(this.castSrc, this.castContentType); mediaInfo.customData = this.castCustomData; // Manually add text tracks with a `src` attribute. // M3U8's load text tracks in the receiver, handle these in the media loaded event. const subtitles = [...this.querySelectorAll('track')].filter( ({ kind, src }) => src && (kind === 'subtitles' || kind === 'captions') ); const activeTrackIds = []; let textTrackIdCount = 0; if (subtitles.length) { mediaInfo.tracks = subtitles.map((trackEl) => { const trackId = ++textTrackIdCount; // only activate 1 subtitle text track. if (activeTrackIds.length === 0 && trackEl.track.mode === 'showing') { activeTrackIds.push(trackId); } const track = new chrome.cast.media.Track( trackId, chrome.cast.media.TrackType.TEXT ); track.trackContentId = trackEl.src; track.trackContentType = 'text/vtt'; track.subtype = trackEl.kind === 'captions' ? chrome.cast.media.TextTrackType.CAPTIONS : chrome.cast.media.TextTrackType.SUBTITLES; track.name = trackEl.label; track.language = trackEl.srclang; return track; }); } if (this.castStreamType === 'live') { mediaInfo.streamType = chrome.cast.media.StreamType.LIVE; } else { mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; } mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); mediaInfo.metadata.title = this.title; mediaInfo.metadata.images = [{ url: this.poster }]; if (isHls(this.castSrc)) { const segmentFormat = await getPlaylistSegmentFormat(this.castSrc); const isFragmentedMP4 = segmentFormat?.includes('m4s') || segmentFormat?.includes('mp4'); if (isFragmentedMP4) { mediaInfo.hlsSegmentFormat = chrome.cast.media.HlsSegmentFormat.FMP4; mediaInfo.hlsVideoSegmentFormat = chrome.cast.media.HlsVideoSegmentFormat.FMP4; } } const request = new chrome.cast.media.LoadRequest(mediaInfo); request.currentTime = super.currentTime ?? 0; request.autoplay = !this.#localState.paused; request.activeTrackIds = activeTrackIds; await currentSession()?.loadMedia(request); this.dispatchEvent(new Event('volumechange')); } play() { if (this.#castPlayer) { if (this.#castPlayer.isPaused) { this.#castPlayer.controller?.playOrPause(); } return; } return super.play(); } pause() { if (this.#castPlayer) { if (!this.#castPlayer.isPaused) { this.#castPlayer.controller?.playOrPause(); } return; } super.pause(); } /** * @see https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastOptions * @readonly * * @typedef {Object} CastOptions * @property {string} [receiverApplicationId='CC1AD845'] - The app id of the cast receiver. * @property {string} [autoJoinPolicy='origin_scoped'] - The auto join policy. * @property {string} [language='en-US'] - The language to use for the cast receiver. * @property {boolean} [androidReceiverCompatible=false] - Whether to use the Cast Connect. * @property {boolean} [resumeSavedSession=true] - Whether to resume the last session. * * @return {CastOptions} */ get castOptions() { return this.#castOptions; } get castReceiver() { return this.getAttribute('cast-receiver') ?? undefined; } set castReceiver(val) { if (this.castReceiver == val) return; this.setAttribute('cast-receiver', `${val}`); } // Allow the cast source url to be different than