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 (5990 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 space.subread.overlay.core.PlayClock
15 
16 /**
17  * Gives the position of the media player to other apps, and takes play, pause and seek from them.
18  *
19  * A reader app, for example KOReader with the SubRead plugin, has no notification access, so it
20  * cannot see the media session of the player. This app has that access, so it answers for them.
21  *
22  * `query` returns one row with one column, `state`. Its value is one line:
23  *
24  *     playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook
25  *
26  * The position is in milliseconds, computed for the moment of the query. A problem is one line
27  * `error=<reason>`: `no_notification_access` or `no_player`.
28  *
29  * `call` takes the method `play`, `pause` or `seek` (the argument is the position in
30  * milliseconds), and returns the state line in the bundle key `state`.
31  *
32  * A caption app, one that makes captions from live audio, sends its lines with the method `line`
33  * (the argument is the text, the extra `partial` is true while the sentence goes on) and `end`
34  * when it stops. The panel shows the newest line, in place of the subtitle file, until `end`.
35  * The bundle key `live` answers `ok`, or the reason the panel cannot show the line:
36  * `no_notification_access`, `no_overlay_permission` or `panel_hidden`.
37  *
38  * The provider is open to each app. Each app can already send the media keys to the player, so
39  * this gives no new control over the device. A line is shown, not kept or sent.
40  */
41 class PlayerProvider : ContentProvider() {
42 
43     private val sessions by lazy { context!!.getSystemService(MediaSessionManager::class.java) }
44     private val listener by lazy { ComponentName(context!!, MediaListener::class.java) }
45 
46     override fun onCreate(): Boolean = true
47 
48     override fun query(
49         uri: Uri,
50         projection: Array<String>?,
51         selection: String?,
52         selectionArgs: Array<String>?,
53         sortOrder: String?,
54     ): Cursor = MatrixCursor(arrayOf(COLUMN_STATE)).apply { addRow(arrayOf(stateLine())) }
55 
56     override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
57         if (method == METHOD_LINE || method == METHOD_END) {
58             val listener = MediaListener.instance
59             val answer = when {
60                 listener == null -> ERROR_NO_ACCESS
61                 method == METHOD_END -> listener.endLive()
62                 else -> listener.liveLine(arg.orEmpty(), extras?.getBoolean(EXTRA_PARTIAL) == true)
63             }
64             return Bundle().apply { putString(KEY_LIVE, answer) }
65         }
66         val player = runCatching { player() }.getOrNull()
67         val controls = player?.transportControls
68         when (method) {
69             METHOD_PLAY -> controls?.play()
70             METHOD_PAUSE -> controls?.pause()
71             METHOD_SEEK -> arg?.toLongOrNull()?.let { controls?.seekTo(it) }
72         }
73         return Bundle().apply { putString(COLUMN_STATE, stateLine()) }
74     }
75 
76     /** The session that plays, else the first active one. Null when there is none. */
77     private fun player(): MediaController? {
78         val active = sessions.getActiveSessions(listener)
79         return active.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } ?: active.firstOrNull()
80     }
81 
82     private fun stateLine(): String {
83         val player = try {
84             player()
85         } catch (e: SecurityException) {
86             return "error=$ERROR_NO_ACCESS"
87         } ?: return "error=$ERROR_NO_PLAYER"
88         val state = player.playbackState ?: return "error=$ERROR_NO_PLAYER"
89         return stateLine(state, player.packageName, SystemClock.elapsedRealtime())
90     }
91 
92     override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.space.subread.overlay.state"
93 
94     override fun insert(uri: Uri, values: ContentValues?): Uri? = null
95 
96     override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
97 
98     override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
99 
100     companion object {
101         // The debug build has its own authority, so it installs next to the release.
102         const val AUTHORITY = BuildConfig.APPLICATION_ID + ".player"
103         const val COLUMN_STATE = "state"
104         const val METHOD_PLAY = "play"
105         const val METHOD_PAUSE = "pause"
106         const val METHOD_SEEK = "seek"
107         const val METHOD_LINE = "line"
108         const val METHOD_END = "end"
109         const val EXTRA_PARTIAL = "partial"
110         const val KEY_LIVE = "live"
111         const val LIVE_OK = "ok"
112         const val ERROR_NO_ACCESS = "no_notification_access"
113         const val ERROR_NO_PLAYER = "no_player"
114         const val ERROR_NO_OVERLAY = "no_overlay_permission"
115         const val ERROR_PANEL_HIDDEN = "panel_hidden"
116 
117         /** The state line for a report of the player, at [nowMs] on the clock of the device. */
118         fun stateLine(state: PlaybackState, packageName: String, nowMs: Long): String {
119             val playing = state.state == PlaybackState.STATE_PLAYING
120             val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f
121             // A report without a position: the same as the panel, the position is unknown.
122             val position = if (state.position >= 0) {
123                 val reportedAt = state.lastPositionUpdateTime.takeIf { it > 0 } ?: nowMs
124                 PlayClock(state.position, reportedAt, speed, playing).positionAt(nowMs)
125             } else {
126                 -1
127             }
128             return "playing=${if (playing) 1 else 0};position=$position;speed=$speed;package=$packageName"
129         }
130     }
131 }