@7sage/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
475 lines (470 loc) • 17.6 kB
JavaScript
import { listenEvent, effect, untrack, createScope, keysOf, onDispose, DOMEvent, peek } from '../chunks/vidstack-Bu2kfzUd.js';
import { TimeRange } from '../chunks/vidstack-BFg1ZqiG.js';
import { RAFLoop } from '../chunks/vidstack-qh1N5_f_.js';
import { ListSymbol } from '../chunks/vidstack-Dv_LIPFu.js';
import { getCastSessionMedia, getCastContext, getCastSession, hasActiveCastSession, listenCastContextEvent, getCastErrorMessage } from '../chunks/vidstack-DDwbYVHV.js';
class GoogleCastMediaInfoBuilder {
#info;
constructor(src) {
this.#info = new chrome.cast.media.MediaInfo(src.src, src.type);
}
build() {
return this.#info;
}
setStreamType(streamType) {
if (streamType.includes("live")) {
this.#info.streamType = chrome.cast.media.StreamType.LIVE;
} else {
this.#info.streamType = chrome.cast.media.StreamType.BUFFERED;
}
return this;
}
setTracks(tracks) {
this.#info.tracks = tracks.map(this.#buildCastTrack);
return this;
}
setMetadata(title, poster) {
this.#info.metadata = new chrome.cast.media.GenericMediaMetadata();
this.#info.metadata.title = title;
this.#info.metadata.images = [{ url: poster }];
return this;
}
#buildCastTrack(track, trackId) {
const castTrack = new chrome.cast.media.Track(trackId, chrome.cast.media.TrackType.TEXT);
castTrack.name = track.label;
castTrack.trackContentId = track.src;
castTrack.trackContentType = "text/vtt";
castTrack.language = track.language;
castTrack.subtype = track.kind.toUpperCase();
return castTrack;
}
}
class GoogleCastTracksManager {
#cast;
#ctx;
#onNewLocalTracks;
constructor(cast, ctx, onNewLocalTracks) {
this.#cast = cast;
this.#ctx = ctx;
this.#onNewLocalTracks = onNewLocalTracks;
}
setup() {
const syncRemoteActiveIds = this.syncRemoteActiveIds.bind(this);
listenEvent(this.#ctx.audioTracks, "change", syncRemoteActiveIds);
listenEvent(this.#ctx.textTracks, "mode-change", syncRemoteActiveIds);
effect(this.#syncLocalTracks.bind(this));
}
getLocalTextTracks() {
return this.#ctx.$state.textTracks().filter((track) => track.src && track.type === "vtt");
}
#getLocalAudioTracks() {
return this.#ctx.$state.audioTracks();
}
#getRemoteTracks(type) {
const tracks = this.#cast.mediaInfo?.tracks ?? [];
return type ? tracks.filter((track) => track.type === type) : tracks;
}
#getRemoteActiveIds() {
const activeIds = [], activeLocalAudioTrack = this.#getLocalAudioTracks().find((track) => track.selected), activeLocalTextTracks = this.getLocalTextTracks().filter((track) => track.mode === "showing");
if (activeLocalAudioTrack) {
const remoteAudioTracks = this.#getRemoteTracks(chrome.cast.media.TrackType.AUDIO), remoteAudioTrack = this.#findRemoteTrack(remoteAudioTracks, activeLocalAudioTrack);
if (remoteAudioTrack) activeIds.push(remoteAudioTrack.trackId);
}
if (activeLocalTextTracks?.length) {
const remoteTextTracks = this.#getRemoteTracks(chrome.cast.media.TrackType.TEXT);
if (remoteTextTracks.length) {
for (const localTrack of activeLocalTextTracks) {
const remoteTextTrack = this.#findRemoteTrack(remoteTextTracks, localTrack);
if (remoteTextTrack) activeIds.push(remoteTextTrack.trackId);
}
}
}
return activeIds;
}
#syncLocalTracks() {
const localTextTracks = this.getLocalTextTracks();
if (!this.#cast.isMediaLoaded) return;
const remoteTextTracks = this.#getRemoteTracks(chrome.cast.media.TrackType.TEXT);
for (const localTrack of localTextTracks) {
const hasRemoteTrack = this.#findRemoteTrack(remoteTextTracks, localTrack);
if (!hasRemoteTrack) {
untrack(() => this.#onNewLocalTracks?.());
break;
}
}
}
syncRemoteTracks(event) {
if (!this.#cast.isMediaLoaded) return;
const localAudioTracks = this.#getLocalAudioTracks(), localTextTracks = this.getLocalTextTracks(), remoteAudioTracks = this.#getRemoteTracks(chrome.cast.media.TrackType.AUDIO), remoteTextTracks = this.#getRemoteTracks(chrome.cast.media.TrackType.TEXT);
for (const remoteAudioTrack of remoteAudioTracks) {
const hasLocalTrack = this.#findLocalTrack(localAudioTracks, remoteAudioTrack);
if (hasLocalTrack) continue;
const localAudioTrack = {
id: remoteAudioTrack.trackId.toString(),
label: remoteAudioTrack.name,
language: remoteAudioTrack.language,
kind: remoteAudioTrack.subtype ?? "main",
selected: false
};
this.#ctx.audioTracks[ListSymbol.add](localAudioTrack, event);
}
for (const remoteTextTrack of remoteTextTracks) {
const hasLocalTrack = this.#findLocalTrack(localTextTracks, remoteTextTrack);
if (hasLocalTrack) continue;
const localTextTrack = {
id: remoteTextTrack.trackId.toString(),
src: remoteTextTrack.trackContentId,
label: remoteTextTrack.name,
language: remoteTextTrack.language,
kind: remoteTextTrack.subtype.toLowerCase()
};
this.#ctx.textTracks.add(localTextTrack, event);
}
}
syncRemoteActiveIds(event) {
if (!this.#cast.isMediaLoaded) return;
const activeIds = this.#getRemoteActiveIds(), editRequest = new chrome.cast.media.EditTracksInfoRequest(activeIds);
this.#editTracksInfo(editRequest).catch((error) => {
{
this.#ctx.logger?.errorGroup("[vidstack] failed to edit cast tracks info").labelledLog("Edit Request", editRequest).labelledLog("Error", error).dispatch();
}
});
}
#editTracksInfo(request) {
const media = getCastSessionMedia();
return new Promise((resolve, reject) => media?.editTracksInfo(request, resolve, reject));
}
#findLocalTrack(localTracks, remoteTrack) {
return localTracks.find((localTrack) => this.#isMatch(localTrack, remoteTrack));
}
#findRemoteTrack(remoteTracks, localTrack) {
return remoteTracks.find((remoteTrack) => this.#isMatch(localTrack, remoteTrack));
}
// Note: we can't rely on id matching because they will differ between local/remote. A local
// track id might not even exist.
#isMatch(localTrack, remoteTrack) {
return remoteTrack.name === localTrack.label && remoteTrack.language === localTrack.language && remoteTrack.subtype.toLowerCase() === localTrack.kind.toLowerCase();
}
}
class GoogleCastProvider {
$$PROVIDER_TYPE = "GOOGLE_CAST";
scope = createScope();
#player;
#ctx;
#tracks;
#currentSrc = null;
#state = "disconnected";
#currentTime = 0;
#played = 0;
#seekableRange = new TimeRange(0, 0);
#timeRAF = new RAFLoop(this.#onAnimationFrame.bind(this));
#playerEventHandlers;
#reloadInfo = null;
#isIdle = false;
constructor(player, ctx) {
this.#player = player;
this.#ctx = ctx;
this.#tracks = new GoogleCastTracksManager(player, ctx, this.#onNewLocalTracks.bind(this));
}
get type() {
return "google-cast";
}
get currentSrc() {
return this.#currentSrc;
}
/**
* The Google Cast remote player.
*
* @see {@link https://developers.google.com/cast/docs/reference/web_sender/cast.framework.RemotePlayer}
*/
get player() {
return this.#player;
}
/**
* @see {@link https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastContext}
*/
get cast() {
return getCastContext();
}
/**
* @see {@link https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastSession}
*/
get session() {
return getCastSession();
}
/**
* @see {@link https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.Media}
*/
get media() {
return getCastSessionMedia();
}
/**
* Whether the current Google Cast session belongs to this provider.
*/
get hasActiveSession() {
return hasActiveCastSession(this.#currentSrc);
}
setup() {
this.#attachCastContextEventListeners();
this.#attachCastPlayerEventListeners();
this.#tracks.setup();
this.#ctx.notify("provider-setup", this);
}
#attachCastContextEventListeners() {
listenCastContextEvent(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
this.#onCastStateChange.bind(this)
);
}
#attachCastPlayerEventListeners() {
const Event2 = cast.framework.RemotePlayerEventType, handlers = {
[Event2.IS_CONNECTED_CHANGED]: this.#onCastStateChange,
[Event2.IS_MEDIA_LOADED_CHANGED]: this.#onMediaLoadedChange,
[Event2.CAN_CONTROL_VOLUME_CHANGED]: this.#onCanControlVolumeChange,
[Event2.CAN_SEEK_CHANGED]: this.#onCanSeekChange,
[Event2.DURATION_CHANGED]: this.#onDurationChange,
[Event2.IS_MUTED_CHANGED]: this.#onVolumeChange,
[Event2.VOLUME_LEVEL_CHANGED]: this.#onVolumeChange,
[Event2.IS_PAUSED_CHANGED]: this.#onPausedChange,
[Event2.LIVE_SEEKABLE_RANGE_CHANGED]: this.#onProgress,
[Event2.PLAYER_STATE_CHANGED]: this.#onPlayerStateChange
};
this.#playerEventHandlers = handlers;
const handler = this.#onRemotePlayerEvent.bind(this);
for (const type of keysOf(handlers)) {
this.#player.controller.addEventListener(type, handler);
}
onDispose(() => {
for (const type of keysOf(handlers)) {
this.#player.controller.removeEventListener(type, handler);
}
});
}
async play() {
if (!this.#player.isPaused && !this.#isIdle) return;
if (this.#isIdle) {
await this.#reload(false, 0);
return;
}
this.#player.controller?.playOrPause();
}
async pause() {
if (this.#player.isPaused) return;
this.#player.controller?.playOrPause();
}
getMediaStatus(request) {
return new Promise((resolve, reject) => {
this.media?.getStatus(request, resolve, reject);
});
}
setMuted(muted) {
const hasChanged = muted && !this.#player.isMuted || !muted && this.#player.isMuted;
if (hasChanged) this.#player.controller?.muteOrUnmute();
}
setCurrentTime(time) {
this.#player.currentTime = time;
this.#ctx.notify("seeking", time);
this.#player.controller?.seek();
}
setVolume(volume) {
this.#player.volumeLevel = volume;
this.#player.controller?.setVolumeLevel();
}
async loadSource(src) {
if (this.#reloadInfo?.src !== src) this.#reloadInfo = null;
if (hasActiveCastSession(src)) {
this.#resumeSession();
this.#currentSrc = src;
return;
}
this.#ctx.notify("load-start");
const loadRequest = this.#buildLoadRequest(src), errorCode = await this.session.loadMedia(loadRequest);
if (errorCode) {
this.#currentSrc = null;
this.#ctx.notify("error", Error(getCastErrorMessage(errorCode)));
return;
}
this.#currentSrc = src;
}
destroy() {
this.#reset();
this.#endSession();
}
#reset() {
if (!this.#reloadInfo) {
this.#played = 0;
this.#seekableRange = new TimeRange(0, 0);
}
this.#timeRAF.stop();
this.#currentTime = 0;
this.#reloadInfo = null;
}
#resumeSession() {
const resumeSessionEvent = new DOMEvent("resume-session", { detail: this.session });
this.#onMediaLoadedChange(resumeSessionEvent);
const { muted, volume, savedState } = this.#ctx.$state, localState = savedState();
this.setCurrentTime(Math.max(this.#player.currentTime, localState?.currentTime ?? 0));
this.setMuted(muted());
this.setVolume(volume());
if (localState?.paused === false) this.play();
}
#endSession() {
this.cast.endCurrentSession(true);
const { remotePlaybackLoader } = this.#ctx.$state;
remotePlaybackLoader.set(null);
}
#disconnectFromReceiver() {
const { savedState } = this.#ctx.$state;
savedState.set({
paused: this.#player.isPaused,
currentTime: this.#player.currentTime
});
this.#endSession();
}
#onAnimationFrame() {
this.#onCurrentTimeChange();
}
#onRemotePlayerEvent(event) {
this.#playerEventHandlers[event.type].call(this, event);
}
#onCastStateChange(data) {
const castState = this.cast.getCastState(), state = castState === cast.framework.CastState.CONNECTED ? "connected" : castState === cast.framework.CastState.CONNECTING ? "connecting" : "disconnected";
if (this.#state === state) return;
const detail = { type: "google-cast", state }, trigger = this.#createEvent(data);
this.#state = state;
this.#ctx.notify("remote-playback-change", detail, trigger);
if (state === "disconnected") {
this.#disconnectFromReceiver();
}
}
#onMediaLoadedChange(event) {
const hasLoaded = !!this.#player.isMediaLoaded;
if (!hasLoaded) return;
const src = peek(this.#ctx.$state.source);
Promise.resolve().then(() => {
if (src !== peek(this.#ctx.$state.source) || !this.#player.isMediaLoaded) return;
this.#reset();
const duration = this.#player.duration;
this.#seekableRange = new TimeRange(0, duration);
const detail = {
provider: this,
duration,
buffered: new TimeRange(0, 0),
seekable: this.#getSeekableRange()
}, trigger = this.#createEvent(event);
this.#ctx.notify("loaded-metadata", void 0, trigger);
this.#ctx.notify("loaded-data", void 0, trigger);
this.#ctx.notify("can-play", detail, trigger);
this.#onCanControlVolumeChange();
this.#onCanSeekChange(event);
const { volume, muted } = this.#ctx.$state;
this.setVolume(volume());
this.setMuted(muted());
this.#timeRAF.start();
this.#tracks.syncRemoteTracks(trigger);
this.#tracks.syncRemoteActiveIds(trigger);
});
}
#onCanControlVolumeChange() {
this.#ctx.$state.canSetVolume.set(this.#player.canControlVolume);
}
#onCanSeekChange(event) {
const trigger = this.#createEvent(event);
this.#ctx.notify("stream-type-change", this.#getStreamType(), trigger);
}
#getStreamType() {
const streamType = this.#player.mediaInfo?.streamType;
return streamType === chrome.cast.media.StreamType.LIVE ? this.#player.canSeek ? "live:dvr" : "live" : "on-demand";
}
#onCurrentTimeChange() {
if (this.#reloadInfo) return;
const currentTime = this.#player.currentTime;
if (currentTime === this.#currentTime) return;
this.#ctx.notify("time-change", currentTime);
if (currentTime > this.#played) {
this.#played = currentTime;
this.#onProgress();
}
if (this.#ctx.$state.seeking()) {
this.#ctx.notify("seeked", currentTime);
}
this.#currentTime = currentTime;
}
#onDurationChange(event) {
if (!this.#player.isMediaLoaded || this.#reloadInfo) return;
const duration = this.#player.duration, trigger = this.#createEvent(event);
this.#seekableRange = new TimeRange(0, duration);
this.#ctx.notify("duration-change", duration, trigger);
}
#onVolumeChange(event) {
if (!this.#player.isMediaLoaded) return;
const detail = {
muted: this.#player.isMuted,
volume: this.#player.volumeLevel
}, trigger = this.#createEvent(event);
this.#ctx.notify("volume-change", detail, trigger);
}
#onPausedChange(event) {
const trigger = this.#createEvent(event);
if (this.#player.isPaused) {
this.#ctx.notify("pause", void 0, trigger);
} else {
this.#ctx.notify("play", void 0, trigger);
}
}
#onProgress(event) {
const detail = {
seekable: this.#getSeekableRange(),
buffered: new TimeRange(0, this.#played)
}, trigger = event ? this.#createEvent(event) : void 0;
this.#ctx.notify("progress", detail, trigger);
}
#onPlayerStateChange(event) {
const state = this.#player.playerState, PlayerState = chrome.cast.media.PlayerState;
this.#isIdle = state === PlayerState.IDLE;
if (state === PlayerState.PAUSED) return;
const trigger = this.#createEvent(event);
switch (state) {
case PlayerState.PLAYING:
this.#ctx.notify("playing", void 0, trigger);
break;
case PlayerState.BUFFERING:
this.#ctx.notify("waiting", void 0, trigger);
break;
case PlayerState.IDLE:
this.#timeRAF.stop();
this.#ctx.notify("pause");
this.#ctx.notify("end");
break;
}
}
#getSeekableRange() {
return this.#player.liveSeekableRange ? new TimeRange(this.#player.liveSeekableRange.start, this.#player.liveSeekableRange.end) : this.#seekableRange;
}
#createEvent(detail) {
return detail instanceof Event ? detail : new DOMEvent(detail.type, { detail });
}
#buildMediaInfo(src) {
const { streamType, title, poster } = this.#ctx.$state;
return new GoogleCastMediaInfoBuilder(src).setMetadata(title(), poster()).setStreamType(streamType()).setTracks(this.#tracks.getLocalTextTracks()).build();
}
#buildLoadRequest(src) {
const mediaInfo = this.#buildMediaInfo(src), request = new chrome.cast.media.LoadRequest(mediaInfo), savedState = this.#ctx.$state.savedState();
request.autoplay = (this.#reloadInfo?.paused ?? savedState?.paused) === false;
request.currentTime = this.#reloadInfo?.time ?? savedState?.currentTime ?? 0;
return request;
}
async #reload(paused, time) {
const src = peek(this.#ctx.$state.source);
this.#reloadInfo = { src, paused, time };
await this.loadSource(src);
}
#onNewLocalTracks() {
this.#reload(this.#player.isPaused, this.#player.currentTime).catch((error) => {
{
this.#ctx.logger?.errorGroup("[vidstack] cast failed to load new local tracks").labelledLog("Error", error).dispatch();
}
});
}
}
export { GoogleCastProvider };