Recently Written · git

subread-overlay

Shows the lines of an .srt over any Android media player, in time with it. Tap a word to look it up.

git clone https://github.com/equwal/subread-overlay

Log | Files | Refs


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 }