app/src/main/kotlin/space/subread/overlay/Follower.kt (5306 bytes)
1 package space.subread.overlay 2 3 import android.media.MediaMetadata 4 import android.media.session.MediaController 5 import android.media.session.PlaybackState 6 import android.os.Handler 7 import android.os.SystemClock 8 import space.subread.overlay.core.CueIndex 9 import space.subread.overlay.core.PlayClock 10 11 /** 12 * Follows the media player that plays now, and says which subtitle line is on. 13 * 14 * Each Android media player publishes a media session: Voice, VLC, mpv, YouTube, a podcast app. 15 * The session has the position, the time of that position and the speed. The follower computes 16 * the position between two reports, and sleeps until the next line starts. It does not poll. 17 * 18 * A player that publishes no position: Android makes a position for it that starts at zero when 19 * the player starts to play, and gives none while the player is paused. The follower then holds 20 * the last position, so the line stays. The user sets the timing with the line buttons. 21 */ 22 class Follower( 23 private val handler: Handler, 24 private val now: () -> Long = SystemClock::elapsedRealtime, 25 /** 26 * The index of the line that is on in [index] (-1 before the first line), true when there is 27 * a player to follow, and true when that player plays. 28 */ 29 private val onLine: (Int, Boolean, Boolean) -> Unit, 30 ) { 31 var index: CueIndex = CueIndex(emptyList()) 32 set(value) { 33 field = value 34 update() 35 } 36 37 /** Added to the position of the player, in milliseconds: a file with an intro, a player that is late. */ 38 var offsetMs: Long = 0 39 set(value) { 40 field = value 41 update() 42 } 43 44 private var controller: MediaController? = null 45 private var last: PlayClock? = null 46 private val wake = Runnable { update() } 47 48 private val callback = object : MediaController.Callback() { 49 override fun onPlaybackStateChanged(state: PlaybackState?) = update() 50 override fun onSessionDestroyed() = follow(emptyList()) 51 } 52 53 /** 54 * The sessions that are active, in the order of the system: the one that plays is first. 55 * The follower keeps its player while that player is in the list and no other one plays. 56 */ 57 fun follow(controllers: List<MediaController>) { 58 val playing = controllers.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } 59 val kept = controllers.firstOrNull { it.sessionToken == controller?.sessionToken } 60 val next = playing ?: kept ?: controllers.firstOrNull() 61 if (next?.sessionToken != controller?.sessionToken) { 62 controller?.unregisterCallback(callback) 63 controller = next 64 last = null 65 next?.registerCallback(callback, handler) 66 } 67 update() 68 } 69 70 /** True while the player that is followed plays. */ 71 val playing: Boolean 72 get() = controller?.playbackState?.state == PlaybackState.STATE_PLAYING 73 74 /** The title that the player gives its media, or null. */ 75 val title: String? 76 get() = controller?.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)?.takeIf { it.isNotBlank() } 77 78 /** The package of the player, or null. */ 79 val player: String? 80 get() = controller?.packageName 81 82 /** Pauses the player. True when it played. */ 83 fun pause(): Boolean { 84 if (!playing) return false 85 controller?.transportControls?.pause() 86 return true 87 } 88 89 fun play() { 90 controller?.transportControls?.play() 91 } 92 93 fun stop() { 94 handler.removeCallbacks(wake) 95 controller?.unregisterCallback(callback) 96 controller = null 97 } 98 99 /** Makes the line [steps] lines from the line that is on the current line, by a change of [offsetMs]. */ 100 fun shiftLines(steps: Int) { 101 val clock = clock() ?: return 102 if (index.size == 0) return 103 val position = clock.positionAt(now()) 104 val target = (index.indexAt(position + offsetMs) + steps).coerceIn(0, index.size - 1) 105 offsetMs = index.cues[target].startMs - position 106 } 107 108 private fun clock(): PlayClock? { 109 val state = controller?.playbackState ?: return null 110 val playing = state.state == PlaybackState.STATE_PLAYING 111 val clock = if (state.position >= 0) { 112 // Some players leave the time of the report at zero. Then the report is from now. 113 val reportedAt = state.lastPositionUpdateTime.takeIf { it > 0 } ?: now() 114 val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f 115 PlayClock(state.position, reportedAt, speed, playing) 116 } else { 117 // No position in this report: hold the position of the report before. 118 PlayClock(last?.positionAt(now()) ?: 0, now(), 1f, playing) 119 } 120 return clock.also { last = it } 121 } 122 123 private fun update() { 124 handler.removeCallbacks(wake) 125 val clock = clock() 126 if (clock == null) { 127 onLine(-1, false, false) 128 return 129 } 130 val position = clock.positionAt(now()) + offsetMs 131 val at = index.indexAt(position) 132 onLine(at, true, clock.playing) 133 val next = index.nextChangeAfter(position) ?: return 134 clock.waitUntil(next - offsetMs, now())?.let { handler.postDelayed(wake, it) } 135 } 136 }