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 (4962 bytes)

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