Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: full android auto support #5

Merged
merged 20 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# [4.0.0-rc05](https://github.com/doublesymmetry/react-native-track-player/compare/v4.0.0-rc04...v4.0.0-rc05) (2023-06-26)

* **ios:** Fix crash on getting current item
* **android:** Improve preciseness of seeking
* **android:** Improve handling of service foregrounding

# [4.0.0-rc03](https://github.com/doublesymmetry/react-native-track-player/compare/v4.0.0-rc02...v4.0.0-rc03) (2023-03-28)

* **android:** Fixes compilation issue due to uses of Lifecycle (updates kotlin gradle plugin)
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ repositories {
}

dependencies {
implementation 'com.github.lovegaoshi:KotlinAudio:v2.0.0-aa9'
implementation 'com.github.lovegaoshi:KotlinAudio:v2.0.0-aa12'
// used when building against local maven
// implementation "com.github.doublesymmetry:kotlin-audio:1.2.2"

Expand Down
3 changes: 3 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

<application>

<!-- The main service, handles playback, playlists and media buttons -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ class MusicEvents(private val reactContext: ReactContext) : BroadcastReceiver()
const val PLAYBACK_PROGRESS_UPDATED = "playback-progress-updated"
const val PLAYBACK_ERROR = "playback-error"

// Other
const val PLAYER_ERROR = "player-error"

const val EVENT_INTENT = "com.doublesymmetry.trackplayer.event"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.net.Uri
import android.support.v4.media.RatingCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.doublesymmetry.kotlinaudio.models.Capability
import com.doublesymmetry.kotlinaudio.models.RepeatMode
Expand Down Expand Up @@ -90,6 +93,31 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
return Track(context, bundle, musicService.ratingType)
}

private fun hashmapToMediaItem(hashmap: HashMap<String, String>): MediaItem {

val mediaId = hashmap["mediaId"]
val title = hashmap["title"]
val subtitle = hashmap["subtitle"]
val mediaUri = hashmap["mediaUri"]
val iconUri = hashmap["iconUri"]
val playableFlag = if (hashmap["playable"]?.toInt() == 1 ) MediaItem.FLAG_BROWSABLE else MediaItem.FLAG_PLAYABLE
return MediaItem(
MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(title)
.setSubtitle(subtitle)
.setIconUri(if (iconUri != null ) Uri.parse(iconUri) else null)
.setMediaUri(if (mediaUri != null ) Uri.parse(mediaUri) else null)
.build(), playableFlag
)
}

private fun readableArrayToMediaItems(data: ArrayList<HashMap<String, String>>): MutableList<MediaItem> {
return data.map {
hashmapToMediaItem(it)
}.toMutableList()
}

private fun rejectWithException(callback: Promise, exception: Exception) {
when (exception) {
is RejectionException -> {
Expand Down Expand Up @@ -626,4 +654,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(Arguments.fromBundle(musicService.getPlayerStateBundle(musicService.state)))
}
}

@ReactMethod
fun loadBrowseTree(mediaItems: ReadableMap, callback: Promise) = scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.mediaTree = mediaItems.toHashMap().mapValues { readableArrayToMediaItems(it.value as ArrayList<HashMap<String, String>>) }
callback.resolve(musicService.mediaTree.toString())
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package com.doublesymmetry.trackplayer.service
import android.app.*
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.RatingCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_LOW
Expand All @@ -18,6 +20,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.doublesymmetry.kotlinaudio.models.*
import com.doublesymmetry.kotlinaudio.models.NotificationButton.*
import com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer
import com.doublesymmetry.kotlinaudio.players.AAMediaSessionCallBack
import com.doublesymmetry.trackplayer.HeadlessJsMediaService
import com.doublesymmetry.trackplayer.R as TrackPlayerR
import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toMilliseconds
Expand All @@ -28,21 +31,25 @@ import com.doublesymmetry.trackplayer.model.Track
import com.doublesymmetry.trackplayer.model.TrackAudioItem
import com.doublesymmetry.trackplayer.module.MusicEvents
import com.doublesymmetry.trackplayer.module.MusicEvents.Companion.EVENT_INTENT
import com.doublesymmetry.trackplayer.utils.AppForegroundTracker
import com.doublesymmetry.trackplayer.utils.BundleUtils
import com.doublesymmetry.trackplayer.utils.BundleUtils.setRating
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.google.android.exoplayer2.ui.R as ExoPlayerR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flow
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess
import timber.log.Timber

@MainThread
class MusicService : HeadlessJsMediaService() {
private lateinit var player: QueuedAudioPlayer
private val binder = MusicBinder()
private val scope = MainScope()
private var progressUpdateJob: Job? = null
public var mediaTree: Map<String, List<MediaItem>> = HashMap()

/**
* Use [appKilledPlaybackBehavior] instead.
Expand All @@ -56,32 +63,15 @@ class MusicService : HeadlessJsMediaService() {
clientUid: Int,
rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot {
// TODO: verify clientPackageName here.
return MediaBrowserServiceCompat.BrowserRoot("/", null)
}

override fun onLoadChildren(
parentMediaId: String,
result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>
) {
// Browsing not allowed
if ("MY_EMPTY_MEDIA_ROOT_ID" == parentMediaId) {
result.sendResult(null)
return
}

// Assume for example that the music catalog is already loaded/cached.

val mediaItems = emptyList<MediaBrowserCompat.MediaItem>()

// Check if this is the root menu:
if ("MY_MEDIA_ROOT_ID" == parentMediaId) {
// Build the MediaItem objects for the top level,
// and put them in the mediaItems list...
} else {
// Examine the passed parentMediaId to see which submenu we're at,
// and put the children of that menu in the mediaItems list...
}
result.sendResult(mediaItems)
result.sendResult(mediaTree[parentMediaId])
}

enum class AppKilledPlaybackBehavior(val string: String) {
Expand Down Expand Up @@ -143,10 +133,14 @@ class MusicService : HeadlessJsMediaService() {
)
}

val notification = NotificationCompat.Builder(this, name)
val notificationBuilder = NotificationCompat.Builder(this, name)
.setPriority(PRIORITY_LOW)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
.setSmallIcon(ExoPlayerR.drawable.exo_notification_small_icon)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
notificationBuilder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
}
val notification = notificationBuilder.build()
startForeground(EMPTY_NOTIFICATION_ID, notification)
@Suppress("DEPRECATION")
stopForeground(true)
Expand Down Expand Up @@ -182,11 +176,28 @@ class MusicService : HeadlessJsMediaService() {
)

val automaticallyUpdateNotificationMetadata = playerOptions?.getBoolean(AUTO_UPDATE_METADATA, true) ?: true
val mediaSessionCallback = object: AAMediaSessionCallBack {
override fun handlePlayFromMediaId(mediaId: String?, extras: Bundle?) {
Timber.tag("GVA-RNTP").d("RNTP received req to play from mediaID: %s", mediaId)
val emitBundle = extras ?: Bundle()
emit(MusicEvents.BUTTON_PLAY_FROM_ID, emitBundle.apply {
putString("id", mediaId)
})
}

player = QueuedAudioPlayer(this@MusicService, playerConfig, bufferConfig, cacheConfig)
override fun handlePlayFromSearch(query: String?, extras: Bundle?) {
Timber.tag("GVA-RNTP").d("RNTP received req to play from query: %s", query)
val emitBundle = extras ?: Bundle()
emit(MusicEvents.BUTTON_PLAY_FROM_SEARCH, emitBundle.apply {
putString("query", query)
})
}
}
player = QueuedAudioPlayer(this@MusicService, playerConfig, bufferConfig, cacheConfig, mediaSessionCallback)
player.automaticallyUpdateNotificationMetadata = automaticallyUpdateNotificationMetadata
sessionToken = player.getMediaSessionToken()
observeEvents()
setupForegrounding()
}

@MainThread
Expand Down Expand Up @@ -503,6 +514,116 @@ class MusicService : HeadlessJsMediaService() {
emit(MusicEvents.PLAYBACK_QUEUE_ENDED, bundle)
}

@Suppress("DEPRECATION")
fun isForegroundService(): Boolean {
val manager = baseContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
if (MusicService::class.java.name == service.service.className) {
return service.foreground
}
}
Timber.e("isForegroundService found no matching service")
return false
}

@MainThread
private fun setupForegrounding() {
// Implementation based on https://github.com/Automattic/pocket-casts-android/blob/ee8da0c095560ef64a82d3a31464491b8d713104/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt#L218
var notificationId: Int? = null
var notification: Notification? = null
var stopForegroundWhenNotOngoing = false
var removeNotificationWhenNotOngoing = false

fun startForegroundIfNecessary() {
if (isForegroundService()) {
Timber.d("skipping foregrounding as the service is already foregrounded")
return
}
if (notification == null) {
Timber.d("can't startForeground as the notification is null")
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
notificationId!!,
notification!!,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(notificationId!!, notification)
}
Timber.d("notification has been foregrounded")
} catch (error: Exception) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
error is ForegroundServiceStartNotAllowedException
) {
Timber.e(
"ForegroundServiceStartNotAllowedException: App tried to start a foreground Service when it was not allowed to do so.",
error
)
emit(MusicEvents.PLAYER_ERROR, Bundle().apply {
putString("message", error.message)
putString("code", "android-foreground-service-start-not-allowed")
});
}
}
}

scope.launch {
val BACKGROUNDABLE_STATES = listOf(
AudioPlayerState.IDLE,
AudioPlayerState.ENDED,
AudioPlayerState.STOPPED,
AudioPlayerState.ERROR,
AudioPlayerState.PAUSED
)
val REMOVABLE_STATES = listOf(
AudioPlayerState.IDLE,
AudioPlayerState.STOPPED,
AudioPlayerState.ERROR
)
val LOADING_STATES = listOf(
AudioPlayerState.LOADING,
AudioPlayerState.READY,
AudioPlayerState.BUFFERING
)
var stateCount = 0
event.stateChange.collect {
stateCount++
if (it in LOADING_STATES) return@collect;
// Skip initial idle state, since we are only interested when
// state becomes idle after not being idle
stopForegroundWhenNotOngoing = stateCount > 1 && it in BACKGROUNDABLE_STATES
removeNotificationWhenNotOngoing = stopForegroundWhenNotOngoing && it in REMOVABLE_STATES
}
}

scope.launch {
event.notificationStateChange.collect {
when (it) {
is NotificationState.POSTED -> {
Timber.d("notification posted with id=%s, ongoing=%s", it.notificationId, it.ongoing)
notificationId = it.notificationId;
notification = it.notification;
if (it.ongoing) {
if (player.playWhenReady) {
startForegroundIfNecessary()
}
} else if (stopForegroundWhenNotOngoing) {
if (removeNotificationWhenNotOngoing || isForegroundService()) {
@Suppress("DEPRECATION")
stopForeground(removeNotificationWhenNotOngoing)
Timber.d("stopped foregrounding%s", if (removeNotificationWhenNotOngoing) " and removed notification" else "")
}
}
}
else -> {}
}
}
}
}

@MainThread
private fun observeEvents() {
scope.launch {
Expand Down Expand Up @@ -539,24 +660,6 @@ class MusicService : HeadlessJsMediaService() {
}
}

scope.launch {
event.notificationStateChange.collect {
when (it) {
is NotificationState.POSTED -> {
startForeground(it.notificationId, it.notification)
}
is NotificationState.CANCELLED -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
}
}
}

scope.launch {
event.onPlayerActionTriggeredExternally.collect {
when (it) {
Expand Down
24 changes: 24 additions & 0 deletions docs/docs/guides/play-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
sidebar_position: 4
---

# Play Buttons

UI often needs to display a Play button that changes between three states:

1. Play
2. Pause
3. Spinner (e.g. if playback is being attempted, but sound is paused due to buffering)

Implementing this correctly will take a bit of care. For instance, `usePlaybackState` can return `State.Buffering` even if playback is currently paused. `usePlayWhenReady` is one way to check if the player is attempting to play, but can return true even if `PlaybackState` is `State.Error` or `State.Ended`.

To determine how to render a Play button in its three states correctly, do the following:

* Render the button as a spinner if `playWhenReady` and `state === State.Loading || state === State.Buffering`
* Else render the button as being in the Playing state if `playWhenReady && !(state === State.Error || state === State.Buffering)`
* Otherwise render the button as being in the Paused state

To help with this logic, the API has two utilities:

1. The `useIsPlaying()` hook. This returns `{playing: boolean | undefined, bufferingDuringPlay: boolean | undefined}`, which you can consult to render your play button correctly. You should render a spinner if `bufferingDuringPlay === true`; otherwise render according to `playing`. Values are `undefined` if the player isn't yet in a state where they can be determined.
2. The `async isPlaying()` function, which returns the same result as `useIsPlaying()`, but can be used outside of React components (i.e. without hooks). Note that you can't easily just instead call `getPlaybackState()` to determine the same answer, unless you've accounted for the issues mentioned above.
Loading