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 }