package org.meshbay.client.cast import android.content.Context import android.graphics.Color import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.common.images.WebImage import com.google.android.gms.cast.MediaStatus import com.google.android.gms.cast.MediaTrack import com.google.android.gms.cast.TextTrackStyle import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.bridge.Refused import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** * Discovery and control of a receiver — what cast-chromecast.js does with mDNS * and the cast protocol client, done here with the platform's cast sender SDK * and MediaRouter. The page keeps its own picker: it polls `devices()` while a * scan runs and receivers are listed as they answer. * * The default media receiver: no receiver registration. Needs the vendor's * play services; where they are absent `available()` is false and the page * offers no cast button rather than one that fails. */ class CastControl(private val context: Context) { private val main = Handler(Looper.getMainLooper()) private val devices = ConcurrentHashMap() @Volatile private var scanning = false @Volatile private var deviceName: String? = null private var router: MediaRouter? = null private val selector by lazy { MediaRouteSelector.Builder() .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) .build() } private val routes = object : MediaRouter.Callback() { override fun onRouteAdded(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route) override fun onRouteChanged(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route) override fun onRouteRemoved(r: MediaRouter, route: MediaRouter.RouteInfo) { devices.remove(route.id) } } private fun note(route: MediaRouter.RouteInfo) { if (!route.isDefault && route.isEnabled && route.matchesSelector(selector)) devices[route.id] = route.name } fun available(): Boolean = try { GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS } catch (e: Exception) { false } private fun need() { if (!available()) throw Refused("Casting is not available on this device") } /** * Create the cast context at launch, as the SDK asks. Created only at the * first connect, it was born in the same moment the route was selected, * and the session started without the listener hearing of it: on a fresh * start the first cast timed out, every time (measured). */ fun warmUp() { if (!available()) return try { onMain { CastContext.getSharedInstance(context) } } catch (e: Exception) { Log.w(TAG, "cast context", e) } } private fun onMain(timeoutS: Long = 10, block: () -> T): T { if (Looper.myLooper() == Looper.getMainLooper()) return block() val done = CountDownLatch(1) var value: Result? = null main.post { value = runCatching(block); done.countDown() } if (!done.await(timeoutS, TimeUnit.SECONDS)) throw IllegalStateException("the cast service did not answer") return value!!.getOrThrow() } /** Start a scan and return at once; `devices()` reads what it has found so far. */ fun startScan() { need() onMain { val r = router ?: MediaRouter.getInstance(context).also { router = it } devices.clear() r.removeCallback(routes) r.addCallback(selector, routes, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) r.routes.forEach(::note) scanning = true main.removeCallbacksAndMessages(SCAN_TOKEN) main.postAtTime({ // Keep listening for changes, stop the active (radio-costly) scan. r.addCallback(selector, routes, 0) scanning = false }, SCAN_TOKEN, android.os.SystemClock.uptimeMillis() + SCAN_DURATION_MS) } } fun devices(): JSONObject { val list = JSONArray() for ((id, name) in devices) list.put(JSONObject().put("id", id).put("name", name)) return JSONObject().put("devices", list).put("scanning", scanning) } /** Connect, launch the default receiver, load; answers once the receiver has. */ fun connect(deviceId: String, mediaUrl: String, subtitle: JSONObject?, file: JSONObject? = null, startAt: Double = 0.0): JSONObject { need() val session = onMain { val cast = CastContext.getSharedInstance(context) val r = router ?: MediaRouter.getInstance(context).also { router = it } val route = r.routes.firstOrNull { it.id == deviceId } ?: throw Refused("Unknown device: $deviceId") cast.sessionManager.currentCastSession?.takeIf { it.isConnected }?.let { return@onMain it } val started = CountDownLatch(1) var result: CastSession? = null val listener = object : SessionManagerListener { override fun onSessionStarted(s: CastSession, id: String) { result = s; started.countDown() } override fun onSessionStartFailed(s: CastSession, error: Int) { Log.w(TAG, "session start failed: error=$error") started.countDown() } override fun onSessionStarting(s: CastSession) {} override fun onSessionEnding(s: CastSession) {} override fun onSessionEnded(s: CastSession, error: Int) {} override fun onSessionResuming(s: CastSession, id: String) {} override fun onSessionResumed(s: CastSession, wasSuspended: Boolean) { result = s; started.countDown() } override fun onSessionResumeFailed(s: CastSession, error: Int) {} override fun onSessionSuspended(s: CastSession, reason: Int) {} } cast.sessionManager.addSessionManagerListener(listener, CastSession::class.java) Log.i(TAG, "connect: selecting route '${route.name}'") r.selectRoute(route) deviceName = route.name Pending(started, { result }, { cast.sessionManager.removeSessionManagerListener(listener, CastSession::class.java) }) }.let { s -> when (s) { is CastSession -> s is Pending -> try { // The listener, or the session manager itself: a started // session the listener missed is still a started session. val deadline = System.currentTimeMillis() + 15_000 var found: CastSession? = null while (found == null && System.currentTimeMillis() < deadline) { if (s.done.await(300, TimeUnit.MILLISECONDS)) { found = s.session() ?: throw IllegalStateException("The receiver refused the connection") } else { found = onMain { CastContext.getSharedInstance(context).sessionManager.currentCastSession ?.takeIf { it.isConnected } } if (found != null) Log.i(TAG, "session found connected without its callback") } } found ?: throw IllegalStateException("Connection timeout") } finally { main.post { s.cleanup() } } else -> throw IllegalStateException() } } // The cast framework's objects answer on the main thread only, the // device's name included: read it there, never from this worker. val name = deviceName ?: onMain { session.castDevice?.friendlyName } Log.i(TAG, "session up on '$name', loading the relay stream") val state = load(session, mediaUrl, subtitle, file, startAt) watch(session) return JSONObject().put("deviceName", name ?: JSONObject.NULL).put("playerState", state) } private class Pending(val done: CountDownLatch, val session: () -> CastSession?, val cleanup: () -> Unit) /** * What the receiver does for the whole film, not only at the start: a * freeze on the television is a BUFFERING the phone never hears about * otherwise. Logged with the position, on the main thread the SDK wants. */ private var watched: com.google.android.gms.cast.framework.media.RemoteMediaClient? = null private val watcher = object : com.google.android.gms.cast.framework.media.RemoteMediaClient.Callback() { private var last = -1 override fun onStatusUpdated() { val c = watched ?: return val state = c.playerState if (state == last) return last = state Log.i(TAG, "receiver state ${stateName(state)} at ${c.approximateStreamPosition / 1000.0}s" + (if (state == MediaStatus.PLAYER_STATE_IDLE) " (idle: ${idleName(c.idleReason)})" else "")) } } private fun watch(session: CastSession) = onMain { val c = session.remoteMediaClient ?: return@onMain if (watched === c) return@onMain watched?.unregisterCallback(watcher) c.registerCallback(watcher) watched = c } fun reload(mediaUrl: String, subtitle: JSONObject?, file: JSONObject? = null, startAt: Double = 0.0): JSONObject { need() val session = onMain { CastContext.getSharedInstance(context).sessionManager.currentCastSession } ?: throw Refused("Not connected") val state = load(session, mediaUrl, subtitle, file, startAt) watch(session) return JSONObject().put("playerState", state) } /** * `file` is the relay's description of its file (type and what to show * with it), or null for the stream. A photo is not a stream at all; a * music track is an ordinary buffered file the receiver seeks in itself. */ private fun load(session: CastSession, mediaUrl: String, subtitle: JSONObject?, file: JSONObject?, startAt: Double): String { val contentType = file?.optString("contentType", "") ?: "video/mp4" val photo = file != null && contentType.startsWith("image/") val subUrl = subtitle?.optString("url", "")?.takeIf { it.isNotEmpty() } val info = if (photo) { // A picture, not a stream: nothing to seek, nothing to buffer. MediaInfo.Builder(mediaUrl).setContentType(contentType) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setMetadata(MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)).build() } else if (file != null) { val meta = file.optJSONObject("meta") ?: JSONObject() MediaInfo.Builder(mediaUrl).setContentType(contentType) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply { putString(MediaMetadata.KEY_TITLE, meta.optString("title", "")) putString(MediaMetadata.KEY_ARTIST, meta.optString("artist", "")) putString(MediaMetadata.KEY_ALBUM_TITLE, meta.optString("album", "")) meta.optString("cover", "").takeIf { it.isNotEmpty() && it != "null" } ?.let { addImage(WebImage(Uri.parse(it))) } }).build() } else MediaInfo.Builder(mediaUrl) .setContentType("video/mp4") // LIVE: the relay has no beginning to seek back to — the film's own // timeline lives on this side, and a seek restarts the relay. .setStreamType(MediaInfo.STREAM_TYPE_LIVE) .apply { if (subUrl != null) { setMediaTracks(listOf(MediaTrack.Builder(TEXT_TRACK_ID, MediaTrack.TYPE_TEXT) .setContentId(subUrl).setContentType("text/vtt") .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) .setName(subtitle.optString("label", "").ifEmpty { "Subtitles" }) .setLanguage(subtitle.optString("language", "").ifEmpty { "und" }) .build())) // White on nothing is unreadable over a bright scene; an outline costs no bandwidth. setTextTrackStyle(TextTrackStyle().apply { backgroundColor = Color.TRANSPARENT foregroundColor = Color.WHITE edgeType = TextTrackStyle.EDGE_TYPE_OUTLINE edgeColor = Color.BLACK fontScale = 1.0f fontGenericFamily = TextTrackStyle.FONT_FAMILY_SANS_SERIF }) } }.build() val request = MediaLoadRequestData.Builder().setMediaInfo(info).setAutoplay(true) .apply { if (startAt > 0) setCurrentTime((startAt * 1000).toLong()) } .apply { if (subUrl != null) setActiveTrackIds(longArrayOf(TEXT_TRACK_ID)) }.build() val loaded = CountDownLatch(1) var ok = false var reason = "" onMain { val client = session.remoteMediaClient ?: throw IllegalStateException("The receiver has no media channel") client.load(request).setResultCallback { r -> ok = r.status.isSuccess reason = "code ${r.status.statusCode}" + (r.status.statusMessage?.let { ", $it" } ?: "") // The receiver's own reason, which is the only one there is: // the page is told "refused" and nothing else. Log.i(TAG, "load result: ok=$ok code=${r.status.statusCode} " + "message=${r.status.statusMessage} state=${stateName(client.playerState)} " + "idleReason=${client.idleReason} subtitles=${subUrl != null}") loaded.countDown() } } if (!loaded.await(20, TimeUnit.SECONDS)) { Log.w(TAG, "load: no answer from the receiver in 20 s") throw IllegalStateException("The receiver did not load the stream") } if (!ok) throw IllegalStateException("The receiver refused the stream ($reason)") // A photo has no playing state to wait for: loaded is shown. if (photo) return onMain { stateName(session.remoteMediaClient?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) } // A load the receiver accepted is not a film on the screen: it answers // IDLE, then fetches the stream, and only then plays — or gives up. // So the answer waits for what the receiver actually did (measured // against this receiver: OK/IDLE, then BUFFERING, then PLAYING). val deadline = System.currentTimeMillis() + PLAY_WAIT_MS while (System.currentTimeMillis() < deadline) { val (state, idle) = onMain { val c = session.remoteMediaClient (c?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) to (c?.idleReason ?: MediaStatus.IDLE_REASON_NONE) } if (state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING || state == MediaStatus.PLAYER_STATE_PAUSED) { Log.i(TAG, "receiver is ${stateName(state)}") return stateName(state) } if (state == MediaStatus.PLAYER_STATE_IDLE && idle != MediaStatus.IDLE_REASON_NONE) { Log.w(TAG, "receiver gave up: idle reason ${idleName(idle)}") throw IllegalStateException("The receiver gave up on the stream (${idleName(idle)})") } Thread.sleep(300) } Log.w(TAG, "receiver still not playing after ${PLAY_WAIT_MS / 1000} s") 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() } /** Where in a track the receiver should be; a film seeks by restarting the relay instead. */ fun seek(seconds: Double): JSONObject = command { it.seek(MediaSeekOptions.Builder().setPosition((maxOf(0.0, seconds) * 1000).toLong()).build()) } private fun command(send: (com.google.android.gms.cast.framework.media.RemoteMediaClient) -> com.google.android.gms.common.api.PendingResult): 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) } deviceName = null return true } fun status(): JSONObject = try { if (!available()) JSONObject().put("connected", false).put("deviceName", JSONObject.NULL) 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) JSONObject().put("connected", false).put("deviceName", JSONObject.NULL) } companion object { const val SCAN_DURATION_MS = 6000L private const val TAG = "MeshBayCast" // The one track the receiver is ever told about; changing subtitles // reloads with a new URL under this same id. const val TEXT_TRACK_ID = 1L private val SCAN_TOKEN = Any() private const val PLAY_WAIT_MS = 15000L fun idleName(r: Int) = when (r) { MediaStatus.IDLE_REASON_FINISHED -> "finished" MediaStatus.IDLE_REASON_CANCELED -> "cancelled" MediaStatus.IDLE_REASON_INTERRUPTED -> "interrupted" MediaStatus.IDLE_REASON_ERROR -> "error" else -> "reason $r" } fun stateName(s: Int) = when (s) { MediaStatus.PLAYER_STATE_PLAYING -> "PLAYING" MediaStatus.PLAYER_STATE_PAUSED -> "PAUSED" MediaStatus.PLAYER_STATE_BUFFERING -> "BUFFERING" MediaStatus.PLAYER_STATE_LOADING -> "LOADING" MediaStatus.PLAYER_STATE_IDLE -> "IDLE" else -> "UNKNOWN" } } } /** The cast framework asks the manifest for this: the default media receiver. */ class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions = CastOptions.Builder().setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).build() override fun getAdditionalSessionProviders(context: Context) = null }