From 41094ca4b3bc47e11bb928884d80047b12d88ffc Mon Sep 17 00:00:00 2001 From: hensm Date: Thu, 25 Aug 2022 05:20:02 +0100 Subject: [PATCH] Handle audio device support checking --- ext/src/background/background.ts | 27 ++++++++++++ ext/src/background/selectorManager.ts | 20 ++++++++- ext/src/cast/googleApi.ts | 60 +++++++++++++++++++++++++++ ext/src/cast/sdk/index.ts | 60 +++++++++------------------ ext/src/cast/sdk/media/Media.ts | 2 +- ext/src/cast/utils.ts | 18 ++++++++ ext/src/types.ts | 1 + ext/src/ui/popup/Popup.svelte | 36 ++++++++++------ 8 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 ext/src/cast/googleApi.ts diff --git a/ext/src/background/background.ts b/ext/src/background/background.ts index 0e55754..b9482b4 100755 --- a/ext/src/background/background.ts +++ b/ext/src/background/background.ts @@ -11,6 +11,7 @@ import selectorManager from "./selectorManager"; import { initMenus } from "./menus"; import { initWhitelist } from "./whitelist"; +import { baseConfigStorage, fetchBaseConfig } from "../cast/googleapi"; const _ = browser.i18n.getMessage; @@ -81,6 +82,31 @@ async function notifyBridgeCompat() { } } +/** + * Updates locally-stored base config data if never downloaded or since + * expired. + */ +async function cacheBaseConfig() { + const { baseConfigUpdated } = await baseConfigStorage.get( + "baseConfigUpdated" + ); + + // If never updated or updated more than 48 hours ago + if ( + !baseConfigUpdated || + (Date.now() - baseConfigUpdated) / 1000 >= 172800 + ) { + logger.info("Fetching updated Chromecast base config..."); + const baseConfig = await fetchBaseConfig(); + if (baseConfig) { + await baseConfigStorage.set({ + baseConfig, + baseConfigUpdated: Date.now() + }); + } + } +} + let isInitialized = false; async function init() { @@ -130,4 +156,5 @@ async function init() { }); } +cacheBaseConfig(); init(); diff --git a/ext/src/background/selectorManager.ts b/ext/src/background/selectorManager.ts index 5f9731a..6d830f2 100644 --- a/ext/src/background/selectorManager.ts +++ b/ext/src/background/selectorManager.ts @@ -4,6 +4,7 @@ import options from "../lib/options"; import logger from "../lib/logger"; import { getMediaTypesForPageUrl } from "../lib/utils"; +import { BaseConfig, baseConfigStorage, getAppTag } from "../cast/googleapi"; import { SessionRequest } from "../cast/sdk/classes"; import castManager from "./castManager"; @@ -22,6 +23,16 @@ import { ReceiverSelectorMediaType } from "../types"; +let baseConfig: BaseConfig; +baseConfigStorage + .get("baseConfig") + .then(value => { + baseConfig = value.baseConfig; + }) + .catch(() => { + logger.error("Failed to get Chromecast base config!"); + }); + let sharedSelector: ReceiverSelector; async function getSelector() { if (!sharedSelector) { @@ -215,6 +226,12 @@ async function getSelection( // Ensure status manager is initialized await deviceManager.init(); + let isRequestAppAudioCompatible: Optional; + if (castInstance?.appId) { + const appTag = getAppTag(baseConfig, castInstance.appId); + isRequestAppAudioCompatible = appTag?.supports_audio_only; + } + sharedSelector.open({ receiverDevices: deviceManager.getDevices(), defaultMediaType, @@ -226,7 +243,8 @@ async function getSelection( url: pageUrl, tabId: contextTabId, frameId: contextFrameId, - sessionRequest: selectionOpts?.sessionRequest + sessionRequest: selectionOpts?.sessionRequest, + isRequestAppAudioCompatible } : undefined }); diff --git a/ext/src/cast/googleApi.ts b/ext/src/cast/googleApi.ts new file mode 100644 index 0000000..5d82783 --- /dev/null +++ b/ext/src/cast/googleApi.ts @@ -0,0 +1,60 @@ +import logger from "../lib/logger"; +import { TypedStorageArea } from "../lib/TypedStorageArea"; + +const ENDPOINT = "https://clients3.google.com/cast/chromecast/device"; + +export interface BaseConfig { + app_tags: Array<{ + supports_audio_only: boolean; + suports_video: boolean; + app_id: number; + }>; +} + +export const baseConfigStorage = new TypedStorageArea<{ + baseConfig: BaseConfig; + baseConfigUpdated: number; +}>(browser.storage.local); + +/** + * Fetches Chromecast base config data subset. + */ +export async function fetchBaseConfig(): Promise { + try { + const res = await fetch(`${ENDPOINT}/baseconfig`); + const baseConfig = JSON.parse((await res.text()).slice(4)); + + // Strip other properties + return { app_tags: baseConfig.app_tags }; + } catch (err) { + logger.error("Failed to fetch Chromecast base config!"); + return null; + } +} + +/** + * Get app tag from base config. + * @param baseConfig Base config data. + * @param appId Chromecast app ID. + */ +export function getAppTag(baseConfig: BaseConfig, appId: string) { + // App tag IDs are represented as 32-bit signed integers + const signedAppId = (parseInt(appId, 16) << 32) >> 32; + return baseConfig.app_tags.find(tag => tag.app_id === signedAppId); +} + +/** + * Fetches Chromecast app config. + * + * @param appId Chromecast app ID + * @returns + */ +export async function fetchAppConfig(appId: string) { + try { + const res = await fetch(`${ENDPOINT}/app?a=${appId}`); + return JSON.parse((await res.text()).slice(4)); + } catch (err) { + logger.error("Failed to fetch Chromecast app config!", { appId }); + return null; + } +} diff --git a/ext/src/cast/sdk/index.ts b/ext/src/cast/sdk/index.ts index 2d7fd25..c40e61f 100644 --- a/ext/src/cast/sdk/index.ts +++ b/ext/src/cast/sdk/index.ts @@ -2,11 +2,12 @@ import logger from "../../lib/logger"; -import { ReceiverDevice, ReceiverDeviceCapabilities } from "../../types"; -import { ErrorCallback, SuccessCallback } from "../types"; - +import { Message } from "../../messaging"; import eventMessaging from "../eventMessaging"; +import { ReceiverDevice } from "../../types"; +import { ErrorCallback, SuccessCallback } from "../types"; + import { AutoJoinPolicy, Capability, @@ -38,7 +39,7 @@ import { import Session from "./Session"; import media from "./media"; -import { Message } from "../../messaging"; +import { convertCapabilitiesFlags } from "../utils"; type ReceiverActionListener = ( receiver: Receiver, @@ -51,23 +52,11 @@ type RequestSessionSuccessCallback = (session: Session) => void; * Create `chrome.cast.Receiver` object from receiver device info. */ function createReceiver(device: ReceiverDevice) { - // Convert capabilities bitflag to string array - const capabilities: Capability[] = []; - if (device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT) { - capabilities.push(Capability.VIDEO_OUT); - } else if (device.capabilities & ReceiverDeviceCapabilities.VIDEO_IN) { - capabilities.push(Capability.VIDEO_IN); - } else if (device.capabilities & ReceiverDeviceCapabilities.AUDIO_OUT) { - capabilities.push(Capability.AUDIO_OUT); - } else if (device.capabilities & ReceiverDeviceCapabilities.AUDIO_IN) { - capabilities.push(Capability.AUDIO_IN); - } else if ( - device.capabilities & ReceiverDeviceCapabilities.MULTIZONE_GROUP - ) { - capabilities.push(Capability.MULTIZONE_GROUP); - } - - const receiver = new Receiver(device.id, device.friendlyName, capabilities); + const receiver = new Receiver( + device.id, + device.friendlyName, + convertCapabilitiesFlags(device.capabilities) + ); // Currently only supports CAST receivers receiver.receiverType = ReceiverType.CAST; @@ -178,11 +167,11 @@ export default class { ); const session = new Session( - status.sessionId, // sessionId - status.appId, // appId - status.displayName, // displayName - status.appImages, // appImages - receiver // receiver + status.sessionId, + status.appId, + status.displayName, + status.appImages, + receiver ); session.namespaces = status.namespaces; @@ -221,11 +210,8 @@ export default class { session.namespaces = status.namespaces; session.receiver.volume = status.volume; - const updateListeners = session?._updateListeners; - if (updateListeners) { - for (const listener of updateListeners) { - listener(session.status !== SessionStatus.STOPPED); - } + for (const listener of session._updateListeners) { + listener(session.status !== SessionStatus.STOPPED); } break; @@ -236,12 +222,8 @@ export default class { const session = this.#sessions.get(sessionId); if (session) { session.status = SessionStatus.STOPPED; - - const updateListeners = session?._updateListeners; - if (updateListeners) { - for (const listener of updateListeners) { - listener(false); - } + for (const listener of session._updateListeners) { + listener(false); } } @@ -252,9 +234,7 @@ export default class { const { sessionId, namespace, messageData } = message.data; const session = this.#sessions.get(sessionId); if (session) { - const _messageListeners = session._messageListeners; - const listeners = _messageListeners.get(namespace); - + const listeners = session._messageListeners.get(namespace); if (listeners) { for (const listener of listeners) { listener(namespace, messageData); diff --git a/ext/src/cast/sdk/media/Media.ts b/ext/src/cast/sdk/media/Media.ts index 94a6bcf..2803b83 100644 --- a/ext/src/cast/sdk/media/Media.ts +++ b/ext/src/cast/sdk/media/Media.ts @@ -140,7 +140,7 @@ export default class Media { * information reported by the receiver. */ getEstimatedTime(): number { - if (this.playerState === PlayerState.PLAYING && this._lastUpdateTime) { + if (this.playerState === PlayerState.PLAYING) { return getEstimatedTime({ currentTime: this.currentTime, lastUpdateTime: this._lastUpdateTime, diff --git a/ext/src/cast/utils.ts b/ext/src/cast/utils.ts index 37506da..1f948c6 100644 --- a/ext/src/cast/utils.ts +++ b/ext/src/cast/utils.ts @@ -28,6 +28,24 @@ export function hasRequiredCapabilities( }); } +export function convertCapabilitiesFlags(flags: ReceiverDeviceCapabilities) { + // Convert capabilities bitflag to string array + const capabilities: Capability[] = []; + if (flags & ReceiverDeviceCapabilities.VIDEO_OUT) + capabilities.push(Capability.VIDEO_OUT); + if (flags & ReceiverDeviceCapabilities.VIDEO_IN) + capabilities.push(Capability.VIDEO_IN); + if (flags & ReceiverDeviceCapabilities.AUDIO_OUT) + capabilities.push(Capability.AUDIO_OUT); + if (flags & ReceiverDeviceCapabilities.AUDIO_IN) + capabilities.push(Capability.AUDIO_IN); + + if (flags & ReceiverDeviceCapabilities.MULTIZONE_GROUP) + capabilities.push(Capability.MULTIZONE_GROUP); + + return capabilities; +} + interface GetEstimatedTimeOpts { currentTime: number; lastUpdateTime: number; diff --git a/ext/src/types.ts b/ext/src/types.ts index 509d668..0dea83c 100644 --- a/ext/src/types.ts +++ b/ext/src/types.ts @@ -41,4 +41,5 @@ export interface ReceiverSelectorPageInfo { tabId: number; frameId: number; sessionRequest?: SessionRequest; + isRequestAppAudioCompatible?: boolean; } diff --git a/ext/src/ui/popup/Popup.svelte b/ext/src/ui/popup/Popup.svelte index feab0d6..bf91ab9 100644 --- a/ext/src/ui/popup/Popup.svelte +++ b/ext/src/ui/popup/Popup.svelte @@ -7,6 +7,7 @@ import { ReceiverDevice, + ReceiverDeviceCapabilities, ReceiverSelectionActionType, ReceiverSelectorMediaType, ReceiverSelectorPageInfo @@ -58,6 +59,25 @@ // If an app is not already loaded on the page !(availableMediaTypes & ReceiverSelectorMediaType.App); + /** + * Checks if device is compatible with the requested app and + * capabilities. + */ + function isDeviceCompatible(device: ReceiverDevice) { + // If device is audio-only, check app's audio support flag + if ( + !(device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT) && + pageInfo?.isRequestAppAudioCompatible === false + ) { + return false; + } + + return hasRequiredCapabilities( + device, + pageInfo?.sessionRequest?.capabilities + ); + } + let port: Nullable = null; let browserWindow: Nullable = null; let resizeObserver = new ResizeObserver(() => fitWindowHeight()); @@ -138,17 +158,6 @@ break; case "popup:update": { - /** - * Filter receiver devices without the required - * capabilities. - */ - $deviceStore = message.data.receiverDevices.filter(device => - hasRequiredCapabilities( - device, - pageInfo?.sessionRequest?.capabilities - ) - ); - if ( message.data.availableMediaTypes !== undefined && message.data.defaultMediaType !== undefined @@ -161,6 +170,9 @@ } updateKnownApp(); + + $deviceStore = message.data.receiverDevices; + break; } } @@ -418,7 +430,7 @@ {device} {isMediaTypeAvailable} isAnyMediaTypeAvailable={availableMediaTypes !== - ReceiverSelectorMediaType.None} + ReceiverSelectorMediaType.None && isDeviceCompatible(device)} isAnyConnecting={isConnecting} on:cast={ev => onReceiverCast(ev.detail.device)} on:stop={ev => onReceiverStop(ev.detail.device)}