UNPKG

castable-video

Version:

Cast your video element to the big screen with ease!

352 lines (297 loc) 10.7 kB
/* 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()) { // Don't create a new RemotePlayback when the element is disconnected. // super.disconnectedCallback() may trigger property access that reaches // this getter, which would re-create a RemotePlayback after cleanup. if (!this.isConnected) return undefined; // 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?.(); } disconnectedCallback() { this.#remote?.destroy(); this.#remote = null; privateProps.delete(this); super.disconnectedCallback?.(); } 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 }]; const hlsDetected = await isHls(this.castSrc); if (hlsDetected) { if (!mediaInfo.contentType) { mediaInfo.contentType = 'application/x-mpegURL'; } const { videoFormat, audioFormat } = await getPlaylistSegmentFormat(this.castSrc); const isVideoFMP4 = videoFormat?.includes('m4s') || videoFormat?.includes('mp4') || videoFormat?.includes('m4a'); if (isVideoFMP4) { mediaInfo.hlsSegmentFormat = chrome.cast.media.HlsSegmentFormat.FMP4; mediaInfo.hlsVideoSegmentFormat = chrome.cast.media.HlsVideoSegmentFormat.FMP4; } else if (audioFormat?.includes('aac')) { mediaInfo.hlsSegmentFormat = chrome.cast.media.HlsSegmentFormat.AAC; mediaInfo.hlsVideoSegmentFormat = chrome.cast.media.HlsVideoSegmentFormat.MPEG2_TS; } else if (videoFormat?.includes('ts') || audioFormat?.includes('ts')) { mediaInfo.hlsSegmentFormat = chrome.cast.media.HlsSegmentFormat.TS; mediaInfo.hlsVideoSegmentFormat = chrome.cast.media.HlsVideoSegmentFormat.MPEG2_TS; } } 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 <video src>, could be a blob. get castSrc() { // Try the first <source src> for usage with even more native markup. const currentSrc = this.currentSrc; const resolvedSrc = currentSrc?.startsWith('blob:') ? undefined : currentSrc; return ( this.getAttribute('cast-src') ?? this.querySelector('source')?.src ?? resolvedSrc ?? this.getAttribute('src') ?? undefined ); } set castSrc(val) { if (this.castSrc == val) return; this.setAttribute('cast-src', `${val}`); } get castContentType() { return this.getAttribute('cast-content-type') ?? undefined; } set castContentType(val) { this.setAttribute('cast-content-type', `${val}`); } get castStreamType() { // NOTE: Per https://github.com/video-dev/media-ui-extensions/issues/3 `streamType` may yield `"unknown"` return this.getAttribute('cast-stream-type') ?? this.streamType ?? undefined; } set castStreamType(val) { this.setAttribute('cast-stream-type', `${val}`); } get castCustomData() { return this.#castCustomData; } set castCustomData(val) { const valType = typeof val; if (!['object', 'undefined'].includes(valType)) { console.error(`castCustomData must be nullish or an object but value was of type ${valType}`); return; } this.#castCustomData = val; } get readyState() { if (this.#castPlayer) { switch (this.#castPlayer.playerState) { case chrome.cast.media.PlayerState.IDLE: return 0; case chrome.cast.media.PlayerState.BUFFERING: return 2; default: return 3; } } return super.readyState; } get paused() { if (this.#castPlayer) return this.#castPlayer.isPaused; return super.paused; } get muted() { if (this.#castPlayer) return this.#castPlayer?.isMuted; return super.muted; } set muted(val) { if (this.#castPlayer) { if ((val && !this.#castPlayer.isMuted) || (!val && this.#castPlayer.isMuted)) { this.#castPlayer.controller?.muteOrUnmute(); } return; } super.muted = val; } get volume() { if (this.#castPlayer) return this.#castPlayer?.volumeLevel ?? 1; return super.volume; } set volume(val) { if (this.#castPlayer) { this.#castPlayer.volumeLevel = +val; this.#castPlayer.controller?.setVolumeLevel(); return; } super.volume = val; } get duration() { // castPlayer duration returns `0` when no media is loaded. if (this.#castPlayer && this.#castPlayer?.isMediaLoaded) { return this.#castPlayer?.duration ?? NaN; } return super.duration; } get currentTime() { if (this.#castPlayer && this.#castPlayer?.isMediaLoaded) { return this.#castPlayer?.currentTime ?? 0; } return super.currentTime; } set currentTime(val) { if (this.#castPlayer) { this.#castPlayer.currentTime = val; this.#castPlayer.controller?.seek(); return; } super.currentTime = val; } }; export const CastableVideoMixin = CastableMediaMixin;