UNPKG

@ktt45678/vidstack

Version:

UI component library for building high-quality, accessible video and audio experiences on the web.

478 lines (473 loc) 17.7 kB
import { listenEvent, effect, untrack, createScope, keysOf, onDispose, DOMEvent, peek } from '../chunks/vidstack-fG_Sx3Q9.js'; import { TimeRange } from '../chunks/vidstack-CLRUrTzh.js'; import { ListSymbol, RAFLoop } from '../chunks/vidstack-BXMqlVv4.js'; import { getCastSessionMedia, getCastContext, getCastSession, hasActiveCastSession, listenCastContextEvent, getCastErrorMessage } from '../chunks/vidstack-DkpLxM2C.js'; import '../chunks/vidstack-DdUZGy1h.js'; import '@floating-ui/dom'; class GoogleCastMediaInfoBuilder { 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; } } const REMOTE_TRACK_TEXT_TYPE = chrome.cast.media.TrackType.TEXT, REMOTE_TRACK_AUDIO_TYPE = chrome.cast.media.TrackType.AUDIO; class GoogleCastTracksManager { 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(REMOTE_TRACK_AUDIO_TYPE), remoteAudioTrack = this._findRemoteTrack(remoteAudioTracks, activeLocalAudioTrack); if (remoteAudioTrack) activeIds.push(remoteAudioTrack.trackId); } if (activeLocalTextTracks?.length) { const remoteTextTracks = this._getRemoteTracks(REMOTE_TRACK_TEXT_TYPE); 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(REMOTE_TRACK_TEXT_TYPE); 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(REMOTE_TRACK_AUDIO_TYPE), remoteTextTracks = this._getRemoteTracks(REMOTE_TRACK_TEXT_TYPE); 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 { constructor(_player, _ctx) { this._player = _player; this._ctx = _ctx; this.$$PROVIDER_TYPE = "GOOGLE_CAST"; this.scope = createScope(); this._currentSrc = null; this._state = "disconnected"; this._currentTime = 0; this._played = 0; this._seekableRange = new TimeRange(0, 0); this._timeRAF = new RAFLoop(this._onAnimationFrame.bind(this)); this._reloadInfo = null; this._isIdle = false; this._tracks = new GoogleCastTracksManager( this._player, this._ctx, this._onNewLocalTracks.bind(this) ); } get _notify() { return this._ctx.delegate._notify; } 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._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._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._notify("load-start"); const loadRequest = this._buildLoadRequest(src), errorCode = await this.session.loadMedia(loadRequest); if (errorCode) { this._currentSrc = null; this._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._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._notify("loaded-metadata", void 0, trigger); this._notify("loaded-data", void 0, trigger); this._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._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._notify("time-change", currentTime); if (currentTime > this._played) { this._played = currentTime; this._onProgress(); } if (this._ctx.$state.seeking()) { this._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._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._notify("volume-change", detail, trigger); } _onPausedChange(event) { const trigger = this._createEvent(event); if (this._player.isPaused) { this._notify("pause", void 0, trigger); } else { this._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._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._notify("playing", void 0, trigger); break; case PlayerState.BUFFERING: this._notify("waiting", void 0, trigger); break; case PlayerState.IDLE: this._timeRAF._stop(); this._notify("pause"); this._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 };