Files
pole-book/server/node_modules/castable-video/castable-remote-playback.js

387 lines
12 KiB
JavaScript

/* global chrome, cast */
import {
privateProps,
IterableWeakSet,
InvalidStateError,
NotSupportedError,
onCastApiAvailable,
castContext,
currentSession,
currentMedia,
editTracksInfo,
getMediaStatus,
setCastOptions
} from './castable-utils.js';
const remoteInstances = new IterableWeakSet();
const castElementRef = new WeakSet();
let cf;
onCastApiAvailable(() => {
if (!globalThis.chrome?.cast?.isAvailable) {
// Useful to see in verbose logs if this shows undefined or false.
console.debug('chrome.cast.isAvailable', globalThis.chrome?.cast?.isAvailable);
return;
}
if (!cf) {
cf = cast.framework;
castContext().addEventListener(cf.CastContextEventType.CAST_STATE_CHANGED, (e) => {
remoteInstances.forEach((r) => privateProps.get(r).onCastStateChanged?.(e));
});
castContext().addEventListener(cf.CastContextEventType.SESSION_STATE_CHANGED, (e) => {
remoteInstances.forEach((r) => privateProps.get(r).onSessionStateChanged?.(e));
});
remoteInstances.forEach((r) => privateProps.get(r).init?.());
}
});
let remotePlaybackCallbackIdCount = 0;
/**
* Remote Playback shim for the Google cast SDK.
* https://w3c.github.io/remote-playback/
*/
export class RemotePlayback extends EventTarget {
#media;
#isInit;
#remotePlayer;
#remoteListeners;
#state = 'disconnected';
#available = false;
#callbacks = new Set();
#callbackIds = new WeakMap();
constructor(media) {
super();
this.#media = media;
remoteInstances.add(this);
privateProps.set(this, {
init: () => this.#init(),
onCastStateChanged: () => this.#onCastStateChanged(),
onSessionStateChanged: () => this.#onSessionStateChanged(),
getCastPlayer: () => this.#castPlayer,
});
this.#init();
}
get #castPlayer() {
if (castElementRef.has(this.#media)) return this.#remotePlayer;
return undefined;
}
/**
* https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback/state
* @return {'disconnected'|'connecting'|'connected'}
*/
get state() {
return this.#state;
}
async watchAvailability(callback) {
if (this.#media.disableRemotePlayback) {
throw new InvalidStateError('disableRemotePlayback attribute is present.');
}
this.#callbackIds.set(callback, ++remotePlaybackCallbackIdCount);
this.#callbacks.add(callback);
// https://w3c.github.io/remote-playback/#getting-the-remote-playback-devices-availability-information
queueMicrotask(() => callback(this.#hasDevicesAvailable()));
return remotePlaybackCallbackIdCount;
}
async cancelWatchAvailability(callback) {
if (this.#media.disableRemotePlayback) {
throw new InvalidStateError('disableRemotePlayback attribute is present.');
}
if (callback) {
this.#callbacks.delete(callback);
} else {
this.#callbacks.clear();
}
}
async prompt() {
if (this.#media.disableRemotePlayback) {
throw new InvalidStateError('disableRemotePlayback attribute is present.');
}
if (!globalThis.chrome?.cast?.isAvailable) {
throw new NotSupportedError('The RemotePlayback API is disabled on this platform.');
}
const willDisconnect = castElementRef.has(this.#media);
castElementRef.add(this.#media);
setCastOptions(this.#media.castOptions);
Object.entries(this.#remoteListeners).forEach(([event, listener]) => {
this.#remotePlayer.controller.addEventListener(event, listener);
});
try {
// Open browser cast menu.
await castContext().requestSession();
} catch (err) {
// If there will be no disconnect, reset some state here.
if (!willDisconnect) {
castElementRef.delete(this.#media);
}
// Don't throw an error if disconnecting or cancelling.
if (err === 'cancel') {
return;
}
throw new Error(err);
}
privateProps.get(this.#media)?.loadOnPrompt?.();
}
#disconnect() {
if (!castElementRef.has(this.#media)) return;
Object.entries(this.#remoteListeners).forEach(([event, listener]) => {
this.#remotePlayer.controller.removeEventListener(event, listener);
});
castElementRef.delete(this.#media);
// isMuted is not in savedPlayerState. should we sync this back to local?
this.#media.muted = this.#remotePlayer.isMuted;
this.#media.currentTime = this.#remotePlayer.savedPlayerState.currentTime;
if (this.#remotePlayer.savedPlayerState.isPaused === false) {
this.#media.play();
}
}
#hasDevicesAvailable() {
// Cast state: NO_DEVICES_AVAILABLE, NOT_CONNECTED, CONNECTING, CONNECTED
// https://developers.google.com/cast/docs/reference/web_sender/cast.framework#.CastState
const castState = castContext()?.getCastState();
return castState && castState !== 'NO_DEVICES_AVAILABLE';
}
#onCastStateChanged() {
// Cast state: NO_DEVICES_AVAILABLE, NOT_CONNECTED, CONNECTING, CONNECTED
// https://developers.google.com/cast/docs/reference/web_sender/cast.framework#.CastState
const castState = castContext().getCastState();
if (castElementRef.has(this.#media)) {
if (castState === 'CONNECTING') {
this.#state = 'connecting';
this.dispatchEvent(new Event('connecting'));
}
}
if (!this.#available && castState?.includes('CONNECT')) {
this.#available = true;
for (let callback of this.#callbacks) callback(true);
}
else if (this.#available && (!castState || castState === 'NO_DEVICES_AVAILABLE')) {
this.#available = false;
for (let callback of this.#callbacks) callback(false);
}
}
async #onSessionStateChanged() {
// Session states: NO_SESSION, SESSION_STARTING, SESSION_STARTED, SESSION_START_FAILED,
// SESSION_ENDING, SESSION_ENDED, SESSION_RESUMED
// https://developers.google.com/cast/docs/reference/web_sender/cast.framework#.SessionState
const { SESSION_RESUMED } = cf.SessionState;
if (castContext().getSessionState() === SESSION_RESUMED) {
/**
* Figure out if this was the video that started the resumed session.
* @TODO make this more specific than just checking against the video src!! (WL)
*
* If this video element can get the same unique id on each browser refresh
* it would be possible to pass this unique id w/ `LoadRequest.customData`
* and verify against currentMedia().customData below.
*/
if (this.#media.castSrc === currentMedia()?.media.contentId) {
castElementRef.add(this.#media);
Object.entries(this.#remoteListeners).forEach(([event, listener]) => {
this.#remotePlayer.controller.addEventListener(event, listener);
});
/**
* There is cast framework resume session bug when you refresh the page a few
* times the this.#remotePlayer.currentTime will not be in sync with the receiver :(
* The below status request syncs it back up.
*/
try {
await getMediaStatus(new chrome.cast.media.GetStatusRequest());
} catch (error) {
console.error(error);
}
// Dispatch the play, playing events manually to sync remote playing state.
this.#remoteListeners[cf.RemotePlayerEventType.IS_PAUSED_CHANGED]();
this.#remoteListeners[cf.RemotePlayerEventType.PLAYER_STATE_CHANGED]();
}
}
}
#init() {
if (!cf || this.#isInit) return;
this.#isInit = true;
setCastOptions(this.#media.castOptions);
/**
* @TODO add listeners for addtrack, removetrack (WL)
* This only has an impact on <track> with a `src` because these have to be
* loaded manually in the load() method. This will require a new load() call
* for each added/removed track w/ src.
*/
this.#media.textTracks.addEventListener('change', () => this.#updateRemoteTextTrack());
this.#onCastStateChanged();
this.#remotePlayer = new cf.RemotePlayer();
new cf.RemotePlayerController(this.#remotePlayer);
this.#remoteListeners = {
[cf.RemotePlayerEventType.IS_CONNECTED_CHANGED]: ({ value }) => {
if (value === true) {
this.#state = 'connected';
this.dispatchEvent(new Event('connect'));
} else {
this.#disconnect();
this.#state = 'disconnected';
this.dispatchEvent(new Event('disconnect'));
}
},
[cf.RemotePlayerEventType.DURATION_CHANGED]: () => {
this.#media.dispatchEvent(new Event('durationchange'));
},
[cf.RemotePlayerEventType.VOLUME_LEVEL_CHANGED]: () => {
this.#media.dispatchEvent(new Event('volumechange'));
},
[cf.RemotePlayerEventType.IS_MUTED_CHANGED]: () => {
this.#media.dispatchEvent(new Event('volumechange'));
},
[cf.RemotePlayerEventType.CURRENT_TIME_CHANGED]: () => {
if (!this.#castPlayer?.isMediaLoaded) return;
this.#media.dispatchEvent(new Event('timeupdate'));
},
[cf.RemotePlayerEventType.VIDEO_INFO_CHANGED]: () => {
this.#media.dispatchEvent(new Event('resize'));
},
[cf.RemotePlayerEventType.IS_PAUSED_CHANGED]: () => {
this.#media.dispatchEvent(new Event(this.paused ? 'pause' : 'play'));
},
[cf.RemotePlayerEventType.PLAYER_STATE_CHANGED]: () => {
// Player states: IDLE, PLAYING, PAUSED, BUFFERING
// https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media#.PlayerState
// pause event is handled above.
if (this.#castPlayer?.playerState === chrome.cast.media.PlayerState.PAUSED) {
return;
}
this.#media.dispatchEvent(
new Event(
{
[chrome.cast.media.PlayerState.PLAYING]: 'playing',
[chrome.cast.media.PlayerState.BUFFERING]: 'waiting',
[chrome.cast.media.PlayerState.IDLE]: 'emptied',
}[this.#castPlayer?.playerState]
)
);
},
[cf.RemotePlayerEventType.IS_MEDIA_LOADED_CHANGED]: async () => {
if (!this.#castPlayer?.isMediaLoaded) return;
// mediaInfo is not immediately available due to a bug? wait one tick
await Promise.resolve();
this.#onRemoteMediaLoaded();
},
};
}
#onRemoteMediaLoaded() {
this.#updateRemoteTextTrack();
}
async #updateRemoteTextTrack() {
if (!this.#castPlayer) return;
// Get the tracks w/ trackId's that have been loaded; manually or via a playlist like a M3U8 or MPD.
const remoteTracks = this.#remotePlayer.mediaInfo?.tracks ?? [];
const remoteSubtitles = remoteTracks.filter(
({ type }) => type === chrome.cast.media.TrackType.TEXT
);
const localSubtitles = [...this.#media.textTracks].filter(
({ kind }) => kind === 'subtitles' || kind === 'captions'
);
// Create a new array from the local subs w/ the trackId's from the remote subs.
const subtitles = remoteSubtitles
.map(({ language, name, trackId }) => {
// Find the corresponding local text track and assign the trackId.
const { mode } =
localSubtitles.find(
(local) => local.language === language && local.label === name
) ?? {};
if (mode) return { mode, trackId };
return false;
})
.filter(Boolean);
const hiddenSubtitles = subtitles.filter(
({ mode }) => mode !== 'showing'
);
const hiddenTrackIds = hiddenSubtitles.map(({ trackId }) => trackId);
const showingSubtitle = subtitles.find(({ mode }) => mode === 'showing');
// Note this could also include audio or video tracks, diff against local state.
const activeTrackIds =
currentSession()?.getSessionObj().media[0]
?.activeTrackIds ?? [];
let requestTrackIds = activeTrackIds;
if (activeTrackIds.length) {
// Filter out all local hidden subtitle trackId's.
requestTrackIds = requestTrackIds.filter(
(id) => !hiddenTrackIds.includes(id)
);
}
if (showingSubtitle?.trackId) {
requestTrackIds = [...requestTrackIds, showingSubtitle.trackId];
}
// Remove duplicate ids.
requestTrackIds = [...new Set(requestTrackIds)];
const arrayEquals = (a, b) =>
a.length === b.length && a.every((a) => b.includes(a));
if (!arrayEquals(activeTrackIds, requestTrackIds)) {
try {
const request = new chrome.cast.media.EditTracksInfoRequest(
requestTrackIds
);
await editTracksInfo(request);
} catch (error) {
console.error(error);
}
}
}
}