aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt
blob: 0d18a9051e506b76fd5304a61bc0718d8dcebeea (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
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<String, String>()
    @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 <T> onMain(timeoutS: Long = 10, block: () -> T): T {
        if (Looper.myLooper() == Looper.getMainLooper()) return block()
        val done = CountDownLatch(1)
        var value: Result<T>? = 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<CastSession> {
                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<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) }
        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
}