mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
725 lines
31 KiB
Kotlin
725 lines
31 KiB
Kotlin
package app.seb3thehacker.gearslip.media
|
|
|
|
import android.content.ComponentName
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.graphics.Bitmap
|
|
import android.media.MediaMetadata
|
|
import android.media.Rating
|
|
import android.support.v4.media.MediaBrowserCompat
|
|
import android.support.v4.media.session.MediaControllerCompat
|
|
import android.support.v4.media.session.MediaSessionCompat
|
|
import android.support.v4.media.session.PlaybackStateCompat
|
|
import android.media.browse.MediaBrowser
|
|
import android.media.session.MediaController
|
|
import android.media.session.MediaSession
|
|
import android.media.session.MediaSessionManager
|
|
import android.media.session.PlaybackState
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.os.SystemClock
|
|
import app.seb3thehacker.gearslip.GearslipLog
|
|
import app.seb3thehacker.gearslip.host.KnownApps
|
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
|
|
/** One row of an app's browse tree. */
|
|
class MediaEntry(
|
|
val id: String,
|
|
val title: String,
|
|
val subtitle: String,
|
|
val iconUri: Uri?,
|
|
val iconBitmap: Bitmap?,
|
|
val browsable: Boolean,
|
|
val playable: Boolean,
|
|
)
|
|
|
|
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
|
|
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0)
|
|
|
|
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
|
|
class QueueTrack(
|
|
val queueId: Long,
|
|
val title: String,
|
|
val subtitle: String,
|
|
val iconUri: Uri?,
|
|
val iconBitmap: Bitmap?,
|
|
)
|
|
|
|
/** What is playing, as the car's now-playing panel needs it. */
|
|
class NowPlaying(
|
|
val title: String = "",
|
|
val artist: String = "",
|
|
val album: String = "",
|
|
val art: Bitmap? = null,
|
|
val artUri: Uri? = null,
|
|
val state: Int = PlaybackState.STATE_NONE,
|
|
val positionMs: Long = 0,
|
|
val durationMs: Long = 0,
|
|
val speed: Float = 1f,
|
|
val updatedAt: Long = 0,
|
|
val actions: Long = 0,
|
|
val custom: List<CustomAction> = emptyList(),
|
|
val error: String? = null,
|
|
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
|
|
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
|
/** The track's heart rating, for apps that take one; null when the app doesn't rate by heart. */
|
|
val heart: Boolean? = null,
|
|
) {
|
|
/** The app's own button for [words], matched on its id or name - how most apps offer like, shuffle and repeat. */
|
|
fun customFor(vararg words: String) = custom.firstOrNull { a ->
|
|
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
|
|
}
|
|
|
|
/** The app says it takes searches. Spotify says so and then ignores Gearslip's, so [CarMedia.search] checks. */
|
|
val canSearch get() = (actions and PlaybackState.ACTION_PLAY_FROM_SEARCH) != 0L
|
|
|
|
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
|
val likeAction
|
|
get() = custom.firstOrNull { a ->
|
|
val text = "${a.id} ${a.name}"
|
|
listOf("like", "favorit", "favourit", "heart", "love", "thumb").any { text.contains(it, ignoreCase = true) } &&
|
|
listOf("dislike", "down").none { text.contains(it, ignoreCase = true) }
|
|
}
|
|
val shuffleAction get() = customFor("shuffle")
|
|
|
|
/** The app's buttons that aren't like, shuffle or repeat - "Start radio" and the like. */
|
|
val otherActions: List<CustomAction>
|
|
get() {
|
|
val known = listOf(likeAction, shuffleAction, repeatAction)
|
|
return custom.filter { a -> known.none { it === a } }
|
|
}
|
|
val repeatAction get() = customFor("repeat", "loop")
|
|
|
|
/**
|
|
* Liked or not; null when the app offers no way to like at all. An app's own like button
|
|
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
|
|
*/
|
|
val liked: Boolean?
|
|
get() = heart ?: likeAction?.name?.let { name ->
|
|
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
|
|
}
|
|
|
|
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
|
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
|
|
|
/**
|
|
* Something is actually playing or paused mid-track. A stopped, errored or empty session, or one
|
|
* that has not named a track yet, must not keep the player on screen.
|
|
*/
|
|
val isActive get() = title.isNotEmpty() && when (state) {
|
|
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> false
|
|
else -> true
|
|
}
|
|
|
|
fun canDo(action: Long) = actions and action != 0L
|
|
|
|
/** The position now, advanced from the last report the way the platform's own UIs do. */
|
|
fun currentPosition(): Long {
|
|
if (!playing || state != PlaybackState.STATE_PLAYING) return positionMs
|
|
val elapsed = SystemClock.elapsedRealtime() - updatedAt
|
|
val moved = positionMs + (elapsed * speed).toLong()
|
|
return if (durationMs > 0) moved.coerceAtMost(durationMs) else moved
|
|
}
|
|
}
|
|
|
|
class BrowseState(
|
|
/** Titles of the folders entered so far; empty at the app's root. */
|
|
val trail: List<String> = emptyList(),
|
|
val entries: List<MediaEntry> = emptyList(),
|
|
val loading: Boolean = true,
|
|
val failed: Boolean = false,
|
|
/** The app connected but shares no library with us, so there is nothing to browse - only to control. */
|
|
val unavailable: Boolean = false,
|
|
)
|
|
|
|
/**
|
|
* Talks to one media app the way Android Auto does: connect to its MediaBrowserService, browse
|
|
* its tree, and drive playback through the session it publishes. The app decides what its
|
|
* tree contains and what the buttons do; this only presents it.
|
|
*/
|
|
class CarMedia(private val context: Context) {
|
|
|
|
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
|
|
|
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
|
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS, NEEDS_APP_RUNNING }
|
|
|
|
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
|
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
|
|
|
private val main = Handler(Looper.getMainLooper())
|
|
private var connecting: MediaApp? = null
|
|
|
|
/** The music app Gearslip is attached to, for the assistant to name; null when there's none. */
|
|
val appLabel: String? get() = connecting?.label?.takeIf { controller != null }
|
|
|
|
private val _phase = MutableStateFlow(Phase.IDLE)
|
|
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
|
|
|
private val _browse = MutableStateFlow(BrowseState())
|
|
val browse: StateFlow<BrowseState> = _browse.asStateFlow()
|
|
|
|
private val _now = MutableStateFlow(NowPlaying())
|
|
val now: StateFlow<NowPlaying> = _now.asStateFlow()
|
|
|
|
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
|
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
|
|
|
/** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */
|
|
class Search(val query: String, val ignored: Boolean = false)
|
|
|
|
/**
|
|
* What the app's own library search found for [query]. [entries] is null while it looks.
|
|
* [supported] is false when the app has no library search, so the panel can fall back to
|
|
* "play the best match" (see [search]).
|
|
*/
|
|
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
|
|
|
|
private val _results = MutableStateFlow<Results?>(null)
|
|
val results: StateFlow<Results?> = _results.asStateFlow()
|
|
private var resultsQuery: String? = null
|
|
|
|
private val _search = MutableStateFlow<Search?>(null)
|
|
val search: StateFlow<Search?> = _search.asStateFlow()
|
|
private var searchBefore: NowPlaying? = null
|
|
|
|
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
|
class Modes(
|
|
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
|
val repeat: Int = PlaybackStateCompat.REPEAT_MODE_INVALID,
|
|
)
|
|
|
|
private val _modes = MutableStateFlow(Modes())
|
|
val modes: StateFlow<Modes> = _modes.asStateFlow()
|
|
|
|
private var browser: MediaBrowser? = null
|
|
private var controller: MediaController? = null
|
|
|
|
/**
|
|
* The same session through the support library's controller - the framework one has no
|
|
* shuffle or repeat calls at all. Every app built on MediaSessionCompat or Media3 answers
|
|
* these; one on the bare framework API never reports a mode, and the buttons stay off.
|
|
*/
|
|
private var compat: MediaControllerCompat? = null
|
|
private val compatCallback = object : MediaControllerCompat.Callback() {
|
|
override fun onSessionReady() = refreshModes()
|
|
override fun onShuffleModeChanged(shuffleMode: Int) = refreshModes()
|
|
override fun onRepeatModeChanged(repeatMode: Int) = refreshModes()
|
|
}
|
|
|
|
private fun attach(c: MediaController) {
|
|
controller = c
|
|
c.registerCallback(callback)
|
|
update(c.metadata, c.playbackState)
|
|
_queue.value = queueOf(c.queue)
|
|
compat = runCatching {
|
|
MediaControllerCompat(context, MediaSessionCompat.Token.fromToken(c.sessionToken)).also {
|
|
it.registerCallback(compatCallback, main)
|
|
}
|
|
}.onFailure { GearslipLog.w("media: no support-library view of the session: ${it.message}") }.getOrNull()
|
|
refreshModes()
|
|
}
|
|
|
|
private fun refreshModes() {
|
|
val c = compat
|
|
_modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode)
|
|
}
|
|
private var subscribed: String? = null
|
|
private var rootId: String? = null
|
|
private val path = ArrayDeque<Pair<String, String>>() // id to title
|
|
|
|
fun connect(app: MediaApp) {
|
|
disconnect()
|
|
connecting = app
|
|
_phase.value = Phase.CONNECTING
|
|
_browse.value = BrowseState()
|
|
// Known to refuse its library (Spotify): asking only costs the seconds it takes to say no,
|
|
// so go straight to its playback session.
|
|
if (KnownApps.playerWorks(app.component.packageName)) {
|
|
attachToSession(app)
|
|
return
|
|
}
|
|
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
|
|
override fun onConnected() = this@CarMedia.onConnected(app)
|
|
override fun onConnectionFailed() {
|
|
GearslipLog.w("media: ${app.label} refused the connection to its library")
|
|
attachToSession(app)
|
|
}
|
|
override fun onConnectionSuspended() {
|
|
GearslipLog.w("media: ${app.label} went away")
|
|
_phase.value = Phase.REJECTED
|
|
}
|
|
}, null)
|
|
browser = mb
|
|
runCatching { mb.connect() }.onFailure {
|
|
GearslipLog.e("media: connect threw", it)
|
|
_phase.value = Phase.REJECTED
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Some apps only let Google's own host browse them. Their playback session is still public, so
|
|
* playback can be driven from it: what is playing, play and pause, skip, seek. Only the library
|
|
* is out of reach. Reading another app's session needs Notification access, which the driver
|
|
* grants once on the phone.
|
|
*/
|
|
private fun attachToSession(app: MediaApp, attempt: Int = 0, launched: Boolean = false) {
|
|
if (connecting != app) return
|
|
val manager = context.getSystemService(MediaSessionManager::class.java)
|
|
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
|
val session = try {
|
|
manager.getActiveSessions(listener).firstOrNull { it.packageName == app.component.packageName }
|
|
} catch (e: SecurityException) {
|
|
GearslipLog.w("media: cannot read ${app.label}'s session without notification access")
|
|
_rejection.value = Rejection.NEEDS_NOTIFICATION_ACCESS
|
|
_phase.value = Phase.REJECTED
|
|
return
|
|
}
|
|
if (session == null) {
|
|
// A known session-only app that isn't running won't start by waiting: say so at once.
|
|
val limit = when {
|
|
launched -> SESSION_RETRIES_LAUNCHED
|
|
KnownApps.playerWorks(app.component.packageName) -> 0
|
|
else -> SESSION_RETRIES
|
|
}
|
|
// The app only publishes its session once its service has started; give it a moment.
|
|
if (attempt < limit) {
|
|
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
|
} else {
|
|
GearslipLog.w("media: ${app.label} has no active session to control")
|
|
// A session-only app (Spotify) just isn't running - say so and offer to open it,
|
|
// rather than calling it unsupported.
|
|
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
|
|
Rejection.NEEDS_APP_RUNNING
|
|
} else {
|
|
Rejection.REFUSED
|
|
}
|
|
_phase.value = Phase.REJECTED
|
|
}
|
|
return
|
|
}
|
|
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
|
|
attach(session)
|
|
_browse.value = BrowseState(loading = false, unavailable = true)
|
|
_phase.value = Phase.READY
|
|
}
|
|
|
|
private fun onConnected(app: MediaApp) {
|
|
val mb = browser ?: return
|
|
searchComponent = app.component
|
|
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
|
attach(MediaController(context, mb.sessionToken))
|
|
rootId = mb.root
|
|
path.clear()
|
|
_phase.value = Phase.READY
|
|
open(mb.root)
|
|
}
|
|
|
|
private val callback = object : MediaController.Callback() {
|
|
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
|
|
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
|
|
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
|
|
_queue.value = queueOf(queue)
|
|
}
|
|
}
|
|
|
|
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
|
|
val d = item.description
|
|
QueueTrack(
|
|
queueId = item.queueId,
|
|
title = d.title?.toString().orEmpty(),
|
|
subtitle = d.subtitle?.toString().orEmpty(),
|
|
iconUri = d.iconUri,
|
|
iconBitmap = d.iconBitmap,
|
|
)
|
|
}
|
|
|
|
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
|
|
fun text(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }.orEmpty()
|
|
fun art(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) }
|
|
fun uri(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }?.let(Uri::parse)
|
|
_now.value = NowPlaying(
|
|
title = text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_TITLE),
|
|
artist = text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, MediaMetadata.METADATA_KEY_ARTIST),
|
|
album = text(MediaMetadata.METADATA_KEY_ALBUM),
|
|
art = art(MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_DISPLAY_ICON),
|
|
artUri = uri(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, MediaMetadata.METADATA_KEY_ART_URI, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
|
|
state = state?.state ?: PlaybackState.STATE_NONE,
|
|
positionMs = state?.position ?: 0,
|
|
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0,
|
|
speed = state?.playbackSpeed ?: 1f,
|
|
updatedAt = state?.lastPositionUpdateTime ?: 0,
|
|
actions = state?.actions ?: 0,
|
|
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) },
|
|
error = state?.errorMessage?.toString(),
|
|
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
|
heart = if (controller?.ratingType == Rating.RATING_HEART) {
|
|
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
|
|
} else null,
|
|
)
|
|
checkSearch()
|
|
}
|
|
|
|
// --- browsing ------------------------------------------------------------------------
|
|
|
|
private fun open(parentId: String) {
|
|
val mb = browser ?: return
|
|
subscribed?.let { runCatching { mb.unsubscribe(it) } }
|
|
subscribed = parentId
|
|
_browse.update { BrowseState(trail = path.map { it.second }, loading = true) }
|
|
mb.subscribe(parentId, object : MediaBrowser.SubscriptionCallback() {
|
|
override fun onChildrenLoaded(parentId: String, children: MutableList<MediaBrowser.MediaItem>) {
|
|
if (parentId != subscribed) return
|
|
_browse.value = BrowseState(
|
|
trail = path.map { it.second },
|
|
entries = children.map(::entryOf),
|
|
loading = false,
|
|
// An empty top level is an app keeping its library to itself, not an empty one.
|
|
unavailable = path.isEmpty() && children.isEmpty(),
|
|
)
|
|
}
|
|
override fun onError(parentId: String) {
|
|
if (parentId != subscribed) return
|
|
GearslipLog.w("media: could not load $parentId")
|
|
_browse.value = BrowseState(trail = path.map { it.second }, loading = false, failed = true)
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
|
|
val d = item.description
|
|
return MediaEntry(
|
|
id = item.mediaId.orEmpty(),
|
|
title = d.title?.toString().orEmpty(),
|
|
subtitle = d.subtitle?.toString().orEmpty(),
|
|
iconUri = d.iconUri,
|
|
iconBitmap = d.iconBitmap,
|
|
browsable = item.isBrowsable,
|
|
playable = item.isPlayable,
|
|
)
|
|
}
|
|
|
|
/** Folders open; tracks play. Some entries are both (an album you can browse or play whole). */
|
|
fun select(entry: MediaEntry) {
|
|
if (entry.browsable) {
|
|
path.addLast(entry.id to entry.title)
|
|
open(entry.id)
|
|
} else if (entry.playable) {
|
|
controller?.transportControls?.playFromMediaId(entry.id, null)
|
|
}
|
|
}
|
|
|
|
fun playAll(entry: MediaEntry) {
|
|
controller?.transportControls?.playFromMediaId(entry.id, null)
|
|
}
|
|
|
|
val canGoUp get() = path.isNotEmpty()
|
|
|
|
fun up() {
|
|
if (path.isEmpty()) return
|
|
path.removeLast()
|
|
open(path.lastOrNull()?.first ?: rootId ?: return)
|
|
}
|
|
|
|
// --- transport -----------------------------------------------------------------------
|
|
|
|
fun togglePlay() {
|
|
val controls = controller?.transportControls ?: return
|
|
if (_now.value.playing) controls.pause() else controls.play()
|
|
}
|
|
/**
|
|
* Pauses whatever the app thinks it's doing. Voice pauses the music to listen, so by the time
|
|
* "pause" is heard the app already looks stopped; a toggle did nothing and the song came back
|
|
* as soon as the microphone let go. A real pause tells the app to stay stopped.
|
|
*/
|
|
fun pause() = controller?.transportControls?.pause()
|
|
fun next() = controller?.transportControls?.skipToNext()
|
|
fun previous() = controller?.transportControls?.skipToPrevious()
|
|
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
|
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
|
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
|
|
|
/**
|
|
* Asks the app to find and play [query]: a song, artist, album or playlist. This goes through
|
|
* the app's playback session, so it works for apps that keep their library from Gearslip, and
|
|
* the app stays in the background. Apps may still ignore it (Spotify only takes Google's), so
|
|
* if nothing new plays within [SEARCH_WAIT_MS] the search is marked ignored.
|
|
*/
|
|
fun search(query: String) {
|
|
val c = controller ?: return
|
|
main.removeCallbacks(searchTimeout)
|
|
searchBefore = _now.value
|
|
_search.value = Search(query)
|
|
GearslipLog.i("media: -> playFromSearch")
|
|
c.transportControls.playFromSearch(query, Bundle())
|
|
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
|
}
|
|
|
|
/**
|
|
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
|
|
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
|
|
* ViVi answer with songs from the library first, then YouTube Music.
|
|
*/
|
|
fun searchLibrary(query: String) {
|
|
val q = query.trim()
|
|
if (q.isEmpty()) {
|
|
clearResults()
|
|
return
|
|
}
|
|
val component = searchComponent
|
|
if (component == null) {
|
|
noLibrarySearch(q)
|
|
return
|
|
}
|
|
resultsQuery = q
|
|
_results.value = Results(q)
|
|
val sb = searchBrowser
|
|
if (sb != null && sb.isConnected) {
|
|
runSearch(sb, q)
|
|
return
|
|
}
|
|
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
|
|
// The framework MediaBrowser has no search; the support library's does, over the same
|
|
// service. Opened on first use only, so apps nobody searches never see a second client.
|
|
lateinit var created: MediaBrowserCompat
|
|
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
|
|
override fun onConnected() {
|
|
resultsQuery?.let { runSearch(created, it) }
|
|
}
|
|
|
|
override fun onConnectionFailed() {
|
|
GearslipLog.w("media: no library connection for search")
|
|
searchBrowser = null
|
|
resultsQuery?.let { noLibrarySearch(it) }
|
|
}
|
|
}, null)
|
|
searchBrowser = created
|
|
runCatching { created.connect() }.onFailure {
|
|
searchBrowser = null
|
|
noLibrarySearch(q)
|
|
}
|
|
}
|
|
|
|
private fun runSearch(sb: MediaBrowserCompat, q: String) {
|
|
runCatching {
|
|
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
|
|
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
|
if (query != resultsQuery) return
|
|
GearslipLog.i("media: library search found ${items.size}")
|
|
val entries = items.map(::entryOfCompat)
|
|
if (query == playFirstFor) {
|
|
playFirstFor = null
|
|
val first = entries.firstOrNull { it.playable } ?: entries.firstOrNull()
|
|
if (first == null) {
|
|
clearResults()
|
|
search(query)
|
|
} else {
|
|
GearslipLog.i("media: playing the library's best match")
|
|
clearResults()
|
|
searchBefore = _now.value
|
|
_search.value = Search(query)
|
|
main.removeCallbacks(searchTimeout)
|
|
controller?.transportControls?.playFromMediaId(first.id, null)
|
|
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
|
}
|
|
return
|
|
}
|
|
_results.value = Results(query, entries)
|
|
}
|
|
|
|
override fun onError(query: String, extras: Bundle?) {
|
|
if (query != resultsQuery) return
|
|
if (query == playFirstFor) {
|
|
playFirstFor = null
|
|
clearResults()
|
|
search(query)
|
|
return
|
|
}
|
|
GearslipLog.i("media: the app has no library search")
|
|
_results.value = Results(query, emptyList(), supported = false)
|
|
}
|
|
})
|
|
}.onFailure {
|
|
GearslipLog.w("media: library search failed: ${it.message}")
|
|
noLibrarySearch(q)
|
|
}
|
|
}
|
|
|
|
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
|
|
val d = item.description
|
|
return MediaEntry(
|
|
id = item.mediaId.orEmpty(),
|
|
title = d.title?.toString().orEmpty(),
|
|
subtitle = d.subtitle?.toString().orEmpty(),
|
|
iconUri = d.iconUri,
|
|
iconBitmap = d.iconBitmap,
|
|
browsable = item.isBrowsable,
|
|
playable = item.isPlayable,
|
|
)
|
|
}
|
|
|
|
/** The library can't search: a voice "play X" falls back to the app's own search; a typed one says so. */
|
|
private fun noLibrarySearch(q: String) {
|
|
if (q == playFirstFor) {
|
|
playFirstFor = null
|
|
clearResults()
|
|
search(q)
|
|
} else {
|
|
_results.value = Results(q, emptyList(), supported = false)
|
|
}
|
|
}
|
|
|
|
/** A voice "play X" waiting on the library search, to play its first match instead of listing them. */
|
|
private var playFirstFor: String? = null
|
|
|
|
/**
|
|
* Plays the best match for [query], for voice. Asks the app's library first and plays its top
|
|
* result, which apps like ViVi honour where they ignore [search]; without a library search,
|
|
* falls back to [search].
|
|
*/
|
|
fun playBestMatch(query: String) {
|
|
val q = query.trim()
|
|
if (q.isEmpty()) return
|
|
if (!canSearchLibrary) {
|
|
search(q)
|
|
return
|
|
}
|
|
playFirstFor = q
|
|
searchLibrary(q)
|
|
}
|
|
|
|
/** The connected app's service, for the search connection; null when nothing is connected. */
|
|
private var searchComponent: android.content.ComponentName? = null
|
|
private var searchBrowser: MediaBrowserCompat? = null
|
|
|
|
fun clearResults() {
|
|
resultsQuery = null
|
|
_results.value = null
|
|
}
|
|
|
|
/** Whether there's a library to search through, the better of the two searches. */
|
|
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
|
|
|
|
fun dismissSearch() {
|
|
main.removeCallbacks(searchTimeout)
|
|
main.removeCallbacks(clearSearch)
|
|
_search.value = null
|
|
}
|
|
|
|
private val searchTimeout = Runnable {
|
|
val pending = _search.value ?: return@Runnable
|
|
if (pending.ignored) return@Runnable
|
|
GearslipLog.w("media: nothing new played after a search")
|
|
_search.value = Search(pending.query, ignored = true)
|
|
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
|
}
|
|
|
|
private val clearSearch = Runnable { _search.value = null }
|
|
|
|
/** Something new is playing: another track, or the same one started from a pause. */
|
|
private fun checkSearch() {
|
|
val pending = _search.value ?: return
|
|
val before = searchBefore ?: return
|
|
val now = _now.value
|
|
if (pending.ignored || !now.playing) return
|
|
if (now.title != before.title || !before.playing) {
|
|
GearslipLog.i("media: search started playback")
|
|
dismissSearch()
|
|
}
|
|
}
|
|
|
|
/** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
|
|
fun toggleLike() {
|
|
val now = _now.value
|
|
now.likeAction?.let { custom(it); return }
|
|
now.heart?.let { controller?.transportControls?.setRating(Rating.newHeartRating(!it)) }
|
|
}
|
|
|
|
fun toggleShuffle() {
|
|
_now.value.shuffleAction?.let { custom(it); return }
|
|
val next = if (_modes.value.shuffle == PlaybackStateCompat.SHUFFLE_MODE_NONE) {
|
|
PlaybackStateCompat.SHUFFLE_MODE_ALL
|
|
} else {
|
|
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
|
}
|
|
compat?.transportControls?.setShuffleMode(next)
|
|
}
|
|
|
|
/** Off, then the whole queue, then this one track, then off again - the order every player uses. */
|
|
fun cycleRepeat() {
|
|
_now.value.repeatAction?.let { custom(it); return }
|
|
val next = when (_modes.value.repeat) {
|
|
PlaybackStateCompat.REPEAT_MODE_NONE -> PlaybackStateCompat.REPEAT_MODE_ALL
|
|
PlaybackStateCompat.REPEAT_MODE_ALL, PlaybackStateCompat.REPEAT_MODE_GROUP -> PlaybackStateCompat.REPEAT_MODE_ONE
|
|
else -> PlaybackStateCompat.REPEAT_MODE_NONE
|
|
}
|
|
compat?.transportControls?.setRepeatMode(next)
|
|
}
|
|
|
|
fun disconnect() {
|
|
connecting = null
|
|
runCatching { searchBrowser?.disconnect() }
|
|
searchBrowser = null
|
|
searchComponent = null
|
|
clearResults()
|
|
main.removeCallbacksAndMessages(null)
|
|
runCatching { compat?.unregisterCallback(compatCallback) }
|
|
compat = null
|
|
_modes.value = Modes()
|
|
runCatching { controller?.unregisterCallback(callback) }
|
|
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
|
|
runCatching { browser?.disconnect() }
|
|
controller = null
|
|
browser = null
|
|
subscribed = null
|
|
_phase.value = Phase.IDLE
|
|
_now.value = NowPlaying()
|
|
_queue.value = emptyList()
|
|
dismissSearch()
|
|
}
|
|
|
|
/**
|
|
* Opens [app] on the phone, then connects once it publishes its session. The driver asks for
|
|
* this from the "Open" button a session-only app (Spotify) shows when it isn't running yet.
|
|
*/
|
|
fun launchAndConnect(app: MediaApp) {
|
|
if (!openApp(app)) return
|
|
disconnect()
|
|
connecting = app
|
|
_phase.value = Phase.CONNECTING
|
|
// A cold start takes a few seconds to publish a session, so wait, then watch for longer.
|
|
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
|
}
|
|
|
|
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
|
private fun openApp(app: MediaApp): Boolean {
|
|
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
|
return runCatching {
|
|
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
GearslipLog.i("media: opened ${app.label} to bring up its session")
|
|
true
|
|
}.getOrElse {
|
|
GearslipLog.w("media: could not open ${app.label}: ${it.message}")
|
|
false
|
|
}
|
|
}
|
|
|
|
private companion object {
|
|
const val SESSION_RETRIES = 6
|
|
const val SESSION_RETRY_MS = 500L
|
|
/** After opening a cold app, how long to wait before the first look for its session. */
|
|
const val LAUNCH_WAIT_MS = 1_500L
|
|
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
|
const val SESSION_RETRIES_LAUNCHED = 16
|
|
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
|
const val SEARCH_WAIT_MS = 6_000L
|
|
const val SEARCH_NOTICE_MS = 6_000L
|
|
}
|
|
}
|