diff options
Diffstat (limited to 'packages')
7 files changed, 102 insertions, 0 deletions
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index 0a2582a..4755531 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -158,6 +158,8 @@ chromecastConnect: (opts) => call('cast:chromecast:connect', opts), chromecastReload: (opts) => call('cast:chromecast:reload', opts), chromecastDisconnect: () => call('cast:chromecast:disconnect'), + chromecastPause: () => call('cast:chromecast:pause'), + chromecastPlay: () => call('cast:chromecast:play'), } } : {}), // Where downloads go, chosen once. A display name comes back, never a URI. diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt index ecad8ec..74a086b 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt @@ -56,6 +56,8 @@ class CastChannels( val o = a.optJSONObject(0) ?: JSONObject() reported { control.reload(relayUrl(o), subtitleOf(o)) } } + "cast:chromecast:pause" -> control.pause() + "cast:chromecast:play" -> control.play() "cast:chromecast:disconnect" -> control.disconnect() else -> throw Refused("Refused: no such channel") } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt index 9f47f42..f574c89 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt @@ -283,6 +283,28 @@ class CastControl(private val context: Context) { return onMain { stateName(session.remoteMediaClient?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) } } + /** The player as a remote: pause and play the receiver, answered once it has. */ + fun pause(): JSONObject = command { it.pause() } + fun play(): JSONObject = command { it.play() } + + private fun command(send: (com.google.android.gms.cast.framework.media.RemoteMediaClient) -> + com.google.android.gms.common.api.PendingResult<com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult>): JSONObject { + need() + val done = CountDownLatch(1) + var ok = false + onMain { + val c = CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient + ?: throw Refused("Not connected") + send(c).setResultCallback { r -> ok = r.status.isSuccess; done.countDown() } + } + if (!done.await(10, TimeUnit.SECONDS)) throw IllegalStateException("The receiver did not answer") + if (!ok) throw IllegalStateException("The receiver refused the command") + return JSONObject().put("playerState", onMain { + stateName(CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient?.playerState + ?: MediaStatus.PLAYER_STATE_UNKNOWN) + }) + } + fun disconnect(): Boolean { if (!available()) return true onMain { CastContext.getSharedInstance(context).sessionManager.endCurrentSession(true) } @@ -295,7 +317,15 @@ class CastControl(private val context: Context) { else onMain { val s = CastContext.getSharedInstance(context).sessionManager.currentCastSession val on = s != null && s.isConnected + val c = if (on) s!!.remoteMediaClient else null JSONObject().put("connected", on).put("deviceName", if (on) (deviceName ?: s!!.castDevice?.friendlyName) else JSONObject.NULL) + // Where the television is, on the stream's timeline (zero at + // the relay's start): the page adds that start. Not the local + // playhead, which started earlier and drifts. + .put("playerState", c?.let { stateName(it.playerState) } ?: JSONObject.NULL) + .put("idleReason", c?.takeIf { it.playerState == MediaStatus.PLAYER_STATE_IDLE } + ?.let { idleName(it.idleReason) } ?: JSONObject.NULL) + .put("position", c?.let { it.approximateStreamPosition / 1000.0 } ?: JSONObject.NULL) } } catch (e: Exception) { Log.w("MeshBay", "cast status", e) diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 7fc8079..55deb5b 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -174,6 +174,7 @@ class CastChromecast { player.on('status', (status) => { debug(`[cast-chromecast] player status: ${status.playerState}`); + this._noteStatus(status); }); debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); @@ -185,6 +186,7 @@ class CastChromecast { }); }); + this._noteStatus(status); debug(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); return { deviceName: device.name, playerState: status.playerState }; } @@ -200,6 +202,7 @@ class CastChromecast { resolve(s); }); }); + this._noteStatus(status); debug(`[cast-chromecast] reloaded, state: ${status.playerState}`); return { playerState: status.playerState }; } @@ -215,10 +218,55 @@ class CastChromecast { this._cleanup(); } + /** + * What the receiver last said, kept so the page can show where the + * *television* is: its playhead is not the local one, which started earlier + * and drifts. `currentTime` is on the stream's timeline — zero at the point + * the relay started — and the page adds that start. + */ + _noteStatus(status) { + if (!status) return; + this._lastStatus = { + playerState: status.playerState || null, + idleReason: status.idleReason || null, + position: Number(status.currentTime) || 0, + at: Date.now(), + }; + } + + /** The receiver's position now: extrapolated while it plays, as its own clock does. */ + _position() { + const s = this._lastStatus; + if (!s) return null; + return s.playerState === 'PLAYING' ? s.position + (Date.now() - s.at) / 1000 : s.position; + } + + async pause() { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.pause((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + + async play() { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.play((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + getStatus() { + const s = this._lastStatus; return { connected: this._client !== null, deviceName: this._connectedDevice?.name || null, + playerState: s ? s.playerState : null, + idleReason: s ? s.idleReason : null, + position: this._client !== null ? this._position() : null, }; } @@ -229,6 +277,7 @@ class CastChromecast { this._client = null; this._player = null; this._connectedDevice = null; + this._lastStatus = null; } } diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 0ad7e71..4c8707c 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2330,6 +2330,10 @@ function registerBridge() { subtitle === undefined ? castRelay.subtitle : subtitle); }); + // The player becomes a remote while casting: these drive the receiver. + handle('cast:chromecast:pause', async () => castChromecast.pause()); + handle('cast:chromecast:play', async () => castChromecast.play()); + handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 665ec1f..43cf317 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -180,6 +180,8 @@ contextBridge.exposeInMainWorld('meshbay', { chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), + chromecastPause: () => ipcRenderer.invoke('cast:chromecast:pause'), + chromecastPlay: () => ipcRenderer.invoke('cast:chromecast:play'), }, // A sink that writes to disk as chunks arrive, never a buffer handed over at diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 749f93e..c131495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -497,6 +497,19 @@ export const cast = { if (!bridge || !bridge.cast) return false; return bridge.cast.chromecastDisconnect(); }, + // Remote control of the receiver. Absent from an older bridge, so callers + // check `canControl` rather than catching a TypeError. + get canControl() { + return Boolean(bridge && bridge.cast && bridge.cast.chromecastPause && bridge.cast.chromecastPlay); + }, + async chromecastPause() { + if (!this.canControl) return null; + return bridge.cast.chromecastPause(); + }, + async chromecastPlay() { + if (!this.canControl) return null; + return bridge.cast.chromecastPlay(); + }, }; /** |