import { v4 as uuid } from "uuid"; import { Logger } from "../../../lib/logger"; import { getEstimatedTime } from "../../utils"; import type { SenderMediaMessage } from "../types"; import { Volume, Error as CastError } from "../classes"; import { ErrorCode } from "../enums"; import { BreakStatus, EditTracksInfoRequest, GetStatusRequest, LiveSeekableRange, MediaInfo, PauseRequest, PlayRequest, QueueData, QueueJumpRequest, QueueInsertItemsRequest, QueueItem, QueueSetPropertiesRequest, QueueRemoveItemsRequest, QueueReorderItemsRequest, QueueUpdateItemsRequest, SeekRequest, StopRequest, VideoInformation, VolumeRequest } from "./classes"; import { PlayerState, RepeatMode } from "./enums"; const logger = new Logger("fx_cast [sdk :: cast.Media]"); export const NS_MEDIA = "urn:x-cast:com.google.cast.media"; type MediaMessageCallback = ( message: DistributiveOmit ) => Promise; const mediaMessageCallbacks = new WeakMap(); export const mediaUpdateListeners = new WeakMap>(); export const mediaLastUpdateTimes = new WeakMap(); /** Creates a Media object and initializes private data. */ export function createMedia( mediaArgs: ConstructorParameters, mediaMessageCallback: MediaMessageCallback ) { const media = new Media(...mediaArgs); mediaMessageCallbacks.set(media, mediaMessageCallback); mediaUpdateListeners.set(media, new Set()); mediaLastUpdateTimes.set(media, 0); return media; } type UpdateListener = (isAlive: boolean) => void; export default class Media { #id = uuid(); get #updateListeners() { const updateListeners = mediaUpdateListeners.get(this); if (!updateListeners) throw logger.error("Missing media update listeners!"); return updateListeners; } get #mediaMessageCallback() { const callback = mediaMessageCallbacks.get(this); if (!callback) throw logger.error("Missing media message callback!"); return callback; } get #lastUpdateTime() { const lastUpdateTime = mediaLastUpdateTimes.get(this); if (lastUpdateTime === undefined) throw logger.error("Missing last update time!"); return lastUpdateTime; } activeTrackIds: Nullable = null; breakStatus?: BreakStatus; currentTime = 0; customData: unknown = null; idleReason: Nullable = null; liveSeekableRange?: LiveSeekableRange; media: Nullable = null; playbackRate = 1; playerState = PlayerState.IDLE; repeatMode = RepeatMode.OFF; supportedMediaCommands: string[] = []; videoInfo?: VideoInformation; volume: Volume = new Volume(); // Queues items: Nullable = null; currentItemId: Nullable = null; loadingItemId: Nullable = null; preloadedItemId: Nullable = null; queueData?: QueueData; constructor(public sessionId: string, public mediaSessionId: number) {} addUpdateListener(listener: UpdateListener) { this.#updateListeners?.add(listener); } removeUpdateListener(listener: UpdateListener) { this.#updateListeners?.delete(listener); } editTracksInfo( editTracksInfoRequest: EditTracksInfoRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...editTracksInfoRequest, type: "EDIT_TRACKS_INFO", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } /** * Estimates the current break clip position based on the last * information reported by the receiver. */ getEstimatedBreakClipTime() { if (this.breakStatus?.currentBreakClipTime === undefined) return; if (this.playerState === PlayerState.PLAYING) { return getEstimatedTime({ currentTime: this.breakStatus.currentBreakClipTime, lastUpdateTime: this.#lastUpdateTime }); } return this.breakStatus.currentBreakClipTime; } /** * Estimates the current break position based on the last * information reported by the receiver. */ getEstimatedBreakTime() { if (this.breakStatus?.currentBreakTime === undefined) return; if (this.playerState === PlayerState.PLAYING) { return getEstimatedTime({ currentTime: this.breakStatus.currentBreakTime, lastUpdateTime: this.#lastUpdateTime }); } return this.breakStatus.currentBreakTime; } getEstimatedLiveSeekableRange() { logger.info("STUB :: Media#getEstimatedLiveSeekableRange"); } /** * Estimates the current playback position based on the last * information reported by the receiver. */ getEstimatedTime(): number { if (this.playerState === PlayerState.PLAYING) { return getEstimatedTime({ currentTime: this.currentTime, lastUpdateTime: this.#lastUpdateTime, playbackRate: this.playbackRate, duration: this.media?.duration }); } return this.currentTime; } /** * Request media status from the receiver application. This will * also trigger any added media update listeners. */ getStatus( getStatusRequest = new GetStatusRequest(), successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...getStatusRequest, type: "MEDIA_GET_STATUS", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } pause( pauseRequest = new PauseRequest(), successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...pauseRequest, type: "PAUSE", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } play( playRequest = new PlayRequest(), successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...playRequest, type: "PLAY", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queueAppendItem( item: QueueItem, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...new QueueInsertItemsRequest([item]), type: "QUEUE_INSERT", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queueInsertItems( queueInsertItemsRequest: QueueInsertItemsRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...queueInsertItemsRequest, type: "QUEUE_INSERT", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queueJumpToItem( itemId: number, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { if (this.items?.find(item => item.itemId === itemId)) { const jumpRequest = new QueueJumpRequest(); jumpRequest.currentItemId = itemId; this.#mediaMessageCallback?.({ ...jumpRequest, type: "QUEUE_UPDATE", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } } queueMoveItemToNewIndex( itemId: number, newIndex: number, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { // Return early if not in queue if (!this.items) { return; } const itemIndex = this.items.findIndex(item => item.itemId === itemId); if (itemIndex !== -1) { // New index must not be negative if (newIndex < 0) { if (errorCallback) { errorCallback(new CastError(ErrorCode.INVALID_PARAMETER)); } } else if (newIndex == itemIndex) { if (successCallback) { successCallback(); } } } else { if (newIndex > itemIndex) { newIndex++; } const reorderItemsRequest = new QueueReorderItemsRequest([itemId]); if (newIndex < this.items.length) { const existingItem = this.items[newIndex]; reorderItemsRequest.insertBefore = existingItem.itemId; } this.#mediaMessageCallback?.({ ...reorderItemsRequest, type: "QUEUE_REORDER", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } } queueNext( successCallback?: () => void, errorCallback?: (err: CastError) => void ) { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = 1; this.#mediaMessageCallback?.({ ...jumpRequest, type: "QUEUE_UPDATE", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queuePrev( successCallback?: () => void, errorCallback?: (err: CastError) => void ) { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = -1; this.#mediaMessageCallback?.({ ...jumpRequest, type: "QUEUE_UPDATE", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queueRemoveItem( itemId: number, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { const item = this.items?.find(item => item.itemId === itemId); if (item) { this.queueRemoveItems( new QueueRemoveItemsRequest([itemId]), successCallback, errorCallback ); } } queueRemoveItems( queueRemoveItemsRequest: QueueRemoveItemsRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...queueRemoveItemsRequest, mediaSessionId: this.mediaSessionId, type: "QUEUE_REMOVE", sessionId: this.sessionId }) .then(successCallback) .catch(errorCallback); } queueReorderItems( queueReorderItemsRequest: QueueReorderItemsRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...queueReorderItemsRequest, mediaSessionId: this.mediaSessionId, type: "QUEUE_REORDER", sessionId: this.sessionId }) .then(successCallback) .catch(errorCallback); } queueSetRepeatMode( repeatMode: string, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { const setPropertiesRequest = new QueueSetPropertiesRequest(); setPropertiesRequest.repeatMode = repeatMode; this.#mediaMessageCallback?.({ ...setPropertiesRequest, type: "QUEUE_UPDATE", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } queueUpdateItems( queueUpdateItemsRequest: QueueUpdateItemsRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...queueUpdateItemsRequest, type: "QUEUE_UPDATE", sessionId: this.sessionId, mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } seek( seekRequest: SeekRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...seekRequest, type: "SEEK", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } setVolume( volumeRequest: VolumeRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { this.#mediaMessageCallback?.({ ...volumeRequest, type: "MEDIA_SET_VOLUME", mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } stop( stopRequest?: StopRequest, successCallback?: () => void, errorCallback?: (err: CastError) => void ) { if (!stopRequest) { stopRequest = new StopRequest(); } this.#mediaMessageCallback?.({ ...stopRequest, type: "STOP", mediaSessionId: this.mediaSessionId }) .then(() => { if (successCallback) { successCallback(); } }) .catch(errorCallback); } supportsCommand(command: string): boolean { return this.supportedMediaCommands.includes(command); } }