aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js2
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt2
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt30
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js49
-rw-r--r--packages/meshbay-client/src/main.js4
-rw-r--r--packages/meshbay-client/src/preload.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js13
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();
+ },
};
/**