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/PlayerProvider.kt (10538 bytes)

1 package space.subread.overlay
2 
3 import android.content.ComponentName
4 import android.content.ContentProvider
5 import android.content.ContentValues
6 import android.database.Cursor
7 import android.database.MatrixCursor
8 import android.media.session.MediaController
9 import android.media.session.MediaSessionManager
10 import android.media.session.PlaybackState
11 import android.net.Uri
12 import android.os.Bundle
13 import android.os.SystemClock
14 import android.provider.DocumentsContract
15 import space.subread.overlay.core.CueIndex
16 import space.subread.overlay.core.PlayClock
17 import space.subread.overlay.core.Srt
18 
19 /**
20  * Gives the position of the media player to other apps, and takes play, pause and seek from them.
21  *
22  * A reader app, for example KOReader with the SubRead plugin, has no notification access, so it
23  * cannot see the media session of the player. This app has that access, so it answers for them.
24  *
25  * `query` returns one row with one column, `state`. Its value is one line:
26  *
27  *     playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook
28  *
29  * The position is in milliseconds, computed for the moment of the query. A problem is one line
30  * `error=<reason>`: `no_notification_access` or `no_player`.
31  *
32  * `query` of the path `line` returns the subtitle line of now, for a flash card app: the row
33  * has `state` and the raw report of the player (`reported_position`, `reported_at` on the
34  * clock of `SystemClock.elapsedRealtime()`, `speed`, `playing`), the `offset` the user set, and
35  * the line: `index` (-1 before the first line), `start`, `end`, `text`, `before`, `after`. The
36  * times of the line are on the clock of the subtitle file: `offset` later than the player. The
37  * query parameter `position` (milliseconds, on the clock of the player) picks the line for
38  * that position in place of the position of now.
39  *
40  * `call` takes the method `play`, `pause` or `seek` (the argument is the position in
41  * milliseconds), and returns the state line in the bundle key `state`.
42  *
43  * A caption app, one that makes captions from live audio, sends its lines with the method `line`
44  * (the argument is the text, the extra `partial` is true while the sentence goes on) and `end`
45  * when it stops. The panel shows the newest line, in place of the subtitle file, until `end`.
46  * The bundle key `live` answers `ok`, or the reason the panel cannot show the line:
47  * `no_notification_access`, `no_overlay_permission` or `panel_hidden`.
48  *
49  * The provider is open to each app. Each app can already send the media keys to the player, so
50  * this gives no new control over the device. A line is shown, not kept or sent.
51  */
52 class PlayerProvider : ContentProvider() {
53 
54     private val sessions by lazy { context!!.getSystemService(MediaSessionManager::class.java) }
55     private val listener by lazy { ComponentName(context!!, MediaListener::class.java) }
56 
57     /** The subtitle file, read once for the `line` rows and again when the user chooses another. */
58     private var cachedFile: String? = null
59     private var cachedModified: Long? = null
60     private var cachedIndex: CueIndex? = null
61     private val cacheLock = Any()
62 
63     override fun onCreate(): Boolean = true
64 
65     override fun query(
66         uri: Uri,
67         projection: Array<String>?,
68         selection: String?,
69         selectionArgs: Array<String>?,
70         sortOrder: String?,
71     ): Cursor {
72         if (uri.lastPathSegment == PATH_LINE) return lineRow(uri.getQueryParameter(PARAM_POSITION)?.toLongOrNull())
73         return MatrixCursor(arrayOf(COLUMN_STATE)).apply { addRow(arrayOf(stateLine())) }
74     }
75 
76     override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
77         if (method == METHOD_LINE || method == METHOD_END) {
78             val listener = MediaListener.instance
79             val answer = when {
80                 listener == null -> ERROR_NO_ACCESS
81                 method == METHOD_END -> listener.endLive()
82                 else -> listener.liveLine(arg.orEmpty(), extras?.getBoolean(EXTRA_PARTIAL) == true)
83             }
84             return Bundle().apply { putString(KEY_LIVE, answer) }
85         }
86         val player = runCatching { player() }.getOrNull()
87         val controls = player?.transportControls
88         when (method) {
89             METHOD_PLAY -> controls?.play()
90             METHOD_PAUSE -> controls?.pause()
91             METHOD_SEEK -> arg?.toLongOrNull()?.let { controls?.seekTo(it) }
92         }
93         return Bundle().apply { putString(COLUMN_STATE, stateLine()) }
94     }
95 
96     /** The session that plays, else the first active one. Null when there is none. */
97     private fun player(): MediaController? {
98         val active = sessions.getActiveSessions(listener)
99         return active.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } ?: active.firstOrNull()
100     }
101 
102     private fun stateLine(): String {
103         val player = try {
104             player()
105         } catch (e: SecurityException) {
106             return "error=$ERROR_NO_ACCESS"
107         } ?: return "error=$ERROR_NO_PLAYER"
108         val state = player.playbackState ?: return "error=$ERROR_NO_PLAYER"
109         return stateLine(state, player.packageName, SystemClock.elapsedRealtime())
110     }
111 
112     /** The `line` row: the report of the player as it came, and the line for its position of now, or for [positionOverride]. */
113     private fun lineRow(positionOverride: Long?): Cursor {
114         val cursor = MatrixCursor(LINE_COLUMNS)
115         val state = runCatching { player()?.playbackState }.getOrNull()
116         val nowMs = SystemClock.elapsedRealtime()
117         val reportedPosition = state?.position?.takeIf { it >= 0 } ?: -1L
118         val reportedAt = if (state == null) -1L else state.lastPositionUpdateTime.takeIf { it > 0 } ?: nowMs
119         val speed = state?.playbackSpeed?.takeIf { it > 0f } ?: 1f
120         val playing = state?.state == PlaybackState.STATE_PLAYING
121         val store = Store(context!!)
122         val offset = store.offsetMs
123         val position = positionOverride
124             ?: if (reportedPosition >= 0) PlayClock(reportedPosition, reportedAt, speed, playing).positionAt(nowMs) else null
125         val index = subtitles(store)
126         val at = if (position != null && index != null) index.indexAt(position + offset) else -1
127         val cues = index?.cues.orEmpty()
128         val cue = cues.getOrNull(at)
129         cursor.addRow(
130             arrayOf<Any?>(
131                 stateLine(), reportedPosition, reportedAt, speed, if (playing) 1 else 0, offset,
132                 at, cue?.startMs, cue?.endMs, cue?.text, cues.getOrNull(at - 1)?.text, cues.getOrNull(at + 1)?.text,
133             ),
134         )
135         return cursor
136     }
137 
138     /** The cues of the chosen subtitle file, or null without one. Read again when the file or its date changed. */
139     private fun subtitles(store: Store): CueIndex? {
140         val file = store.subtitles ?: return null
141         val resolver = context!!.contentResolver
142         val modified = runCatching {
143             resolver.query(file, arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED), null, null, null)?.use {
144                 if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null
145             }
146         }.getOrNull()
147         synchronized(cacheLock) {
148             if (file.toString() == cachedFile && modified == cachedModified) return cachedIndex
149             val cues = runCatching {
150                 resolver.openInputStream(file)!!.use { Srt.parse(it.readBytes().decodeToString()) }
151             }.getOrDefault(emptyList())
152             cachedFile = file.toString()
153             cachedModified = modified
154             cachedIndex = CueIndex(cues)
155             return cachedIndex
156         }
157     }
158 
159     override fun getType(uri: Uri): String =
160         if (uri.lastPathSegment == PATH_LINE) "vnd.android.cursor.item/vnd.space.subread.overlay.line"
161         else "vnd.android.cursor.item/vnd.space.subread.overlay.state"
162 
163     override fun insert(uri: Uri, values: ContentValues?): Uri? = null
164 
165     override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
166 
167     override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
168 
169     companion object {
170         // The debug build has its own authority, so it installs next to the release.
171         const val AUTHORITY = BuildConfig.APPLICATION_ID + ".player"
172         const val PATH_LINE = "line"
173         const val PARAM_POSITION = "position"
174         const val COLUMN_STATE = "state"
175         const val COLUMN_REPORTED_POSITION = "reported_position"
176         const val COLUMN_REPORTED_AT = "reported_at"
177         const val COLUMN_SPEED = "speed"
178         const val COLUMN_PLAYING = "playing"
179         const val COLUMN_OFFSET = "offset"
180         const val COLUMN_INDEX = "index"
181         const val COLUMN_START = "start"
182         const val COLUMN_END = "end"
183         const val COLUMN_TEXT = "text"
184         const val COLUMN_BEFORE = "before"
185         const val COLUMN_AFTER = "after"
186         val LINE_COLUMNS = arrayOf(
187             COLUMN_STATE, COLUMN_REPORTED_POSITION, COLUMN_REPORTED_AT, COLUMN_SPEED, COLUMN_PLAYING, COLUMN_OFFSET,
188             COLUMN_INDEX, COLUMN_START, COLUMN_END, COLUMN_TEXT, COLUMN_BEFORE, COLUMN_AFTER,
189         )
190         const val METHOD_PLAY = "play"
191         const val METHOD_PAUSE = "pause"
192         const val METHOD_SEEK = "seek"
193         const val METHOD_LINE = "line"
194         const val METHOD_END = "end"
195         const val EXTRA_PARTIAL = "partial"
196         const val KEY_LIVE = "live"
197         const val LIVE_OK = "ok"
198         const val ERROR_NO_ACCESS = "no_notification_access"
199         const val ERROR_NO_PLAYER = "no_player"
200         const val ERROR_NO_OVERLAY = "no_overlay_permission"
201         const val ERROR_PANEL_HIDDEN = "panel_hidden"
202 
203         /** The state line for a report of the player, at [nowMs] on the clock of the device. */
204         fun stateLine(state: PlaybackState, packageName: String, nowMs: Long): String {
205             val playing = state.state == PlaybackState.STATE_PLAYING
206             val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f
207             // A report without a position: the same as the panel, the position is unknown.
208             val position = if (state.position >= 0) {
209                 val reportedAt = state.lastPositionUpdateTime.takeIf { it > 0 } ?: nowMs
210                 PlayClock(state.position, reportedAt, speed, playing).positionAt(nowMs)
211             } else {
212                 -1
213             }
214             return "playing=${if (playing) 1 else 0};position=$position;speed=$speed;package=$packageName"
215         }
216     }
217 }