Skip to content
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
19 changes: 19 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

<!-- Keep the standalone local-playback process alive in the background via a
mediaPlayback foreground service (WearPlaybackService). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<uses-feature android:name="android.hardware.type.watch" />

<application
Expand Down Expand Up @@ -48,6 +55,18 @@
</intent-filter>
</activity>

<!-- Hosts standalone local playback as a foreground mediaPlayback service so Wear OS
doesn't kill the process while audio is playing in the background. -->
<service
android:name=".data.WearPlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>

<!-- Receives player state updates from the phone app via Data Layer -->
<service
android:name=".data.WearDataListenerService"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
package com.theveloper.pixelplay.data

import android.app.Application
import android.content.ComponentName
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.media3.common.C
import androidx.media3.common.AudioAttributes
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.theveloper.pixelplay.data.local.LocalSongDao
import com.theveloper.pixelplay.data.local.LocalSongEntity
import com.theveloper.pixelplay.shared.WearLibraryItem
Expand All @@ -28,7 +30,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.io.File
Expand Down Expand Up @@ -63,11 +68,14 @@ data class WearQueueSong(
)

/**
* Repository managing ExoPlayer for standalone local playback on the watch.
* Repository driving standalone local playback on the watch.
* Plays audio files that have been transferred from the phone and stored locally.
*
* Uses a lightweight local ExoPlayer plus MediaSession so Bluetooth headset media buttons
* and other system transport controls route to watch playback correctly.
* The actual ExoPlayer + MediaSession live inside [WearPlaybackService] (a foreground
* MediaSessionService); this repository controls them through a [MediaController]. Hosting playback
* in a foreground service is what keeps the process alive when the app is backgrounded — and the
* MediaSession also routes Bluetooth headset media buttons and system transport controls to watch
* playback.
*/
@Singleton
class WearLocalPlayerRepository @Inject constructor(
Expand All @@ -76,8 +84,7 @@ class WearLocalPlayerRepository @Inject constructor(
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val json = Json { ignoreUnknownKeys = true }
private var exoPlayer: ExoPlayer? = null
private var mediaSession: MediaSession? = null
private var mediaController: MediaController? = null

private val _localPlayerState = MutableStateFlow(WearLocalPlayerState())
val localPlayerState: StateFlow<WearLocalPlayerState> = _localPlayerState.asStateFlow()
Expand Down Expand Up @@ -109,7 +116,6 @@ class WearLocalPlayerRepository @Inject constructor(
companion object {
private const val TAG = "WearLocalPlayer"
private const val POSITION_UPDATE_INTERVAL_MS = 1000L
private const val MEDIA_SESSION_ID = "wear-local-playback"
}

init {
Expand All @@ -131,45 +137,57 @@ class WearLocalPlayerRepository @Inject constructor(
}
}

private fun getOrCreatePlayer(): ExoPlayer {
return exoPlayer ?: ExoPlayer.Builder(application).build().also { player ->
exoPlayer = player
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(androidx.media3.common.C.USAGE_MEDIA)
.setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true,
)
player.setHandleAudioBecomingNoisy(true)
ensureMediaSession(player)
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
if (playbackState == Player.STATE_ENDED) {
stopPositionUpdates()
}
}
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
if (playbackState == Player.STATE_ENDED) {
stopPositionUpdates()
}
}

override fun onIsPlayingChanged(isPlaying: Boolean) {
updateState()
if (isPlaying) startPositionUpdates() else stopPositionUpdates()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
updateState()
if (isPlaying) startPositionUpdates() else stopPositionUpdates()
}

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
})
Timber.tag(TAG).d("ExoPlayer created")
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
}

private fun ensureMediaSession(player: ExoPlayer) {
if (mediaSession != null) return

mediaSession = MediaSession.Builder(application, player)
.setId(MEDIA_SESSION_ID)
.build()
/**
* Connect to (and implicitly start) [WearPlaybackService], returning a [MediaController] that
* drives its ExoPlayer. Playback lives inside that MediaSessionService so Android keeps it alive
* as a foreground "mediaPlayback" service while audio plays — otherwise Wear OS reaps the
* background process after a few minutes and playback dies silently.
*/
private suspend fun getOrConnectController(): MediaController {
mediaController?.let { return it }
return withContext(Dispatchers.Main) {
mediaController?.let { return@withContext it }
val token = SessionToken(
application,
ComponentName(application, WearPlaybackService::class.java),
)
val controller = suspendCancellableCoroutine<MediaController> { continuation ->
val future = MediaController.Builder(application, token).buildAsync()
future.addListener(
{
try {
continuation.resume(future.get())
} catch (e: Exception) {
continuation.resumeWithException(e)
}
},
ContextCompat.getMainExecutor(application),
)
continuation.invokeOnCancellation { MediaController.releaseFuture(future) }
}
mediaController = controller
controller.addListener(playerListener)
Timber.tag(TAG).d("MediaController connected to WearPlaybackService")
controller
}
}

/**
Expand Down Expand Up @@ -279,7 +297,12 @@ class WearLocalPlayerRepository @Inject constructor(
transientCleanupPaths: Set<String> = emptySet(),
) {
withContext(Dispatchers.Main) {
val player = getOrCreatePlayer()
val player = try {
getOrConnectController()
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to connect to WearPlaybackService")
return@withContext
}
if (this@WearLocalPlayerRepository.transientCleanupPaths.isNotEmpty()) {
player.stop()
}
Expand All @@ -306,6 +329,14 @@ class WearLocalPlayerRepository @Inject constructor(
MediaItem.Builder()
.setMediaId(song.songId)
.setUri(song.uri)
// A MediaController drops localConfiguration (the URI) when items cross the
// binder to the service, so stash it in requestMetadata for the service's
// MediaSession.Callback to restore. See WearPlaybackService.
.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setMediaUri(song.uri)
.build()
)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(song.title)
Expand Down Expand Up @@ -334,11 +365,11 @@ class WearLocalPlayerRepository @Inject constructor(
}

fun play() {
exoPlayer?.play()
mediaController?.play()
}

fun togglePlayPause() {
val player = exoPlayer ?: return
val player = mediaController ?: return
if (player.isPlaying) {
player.pause()
} else {
Expand All @@ -347,31 +378,31 @@ class WearLocalPlayerRepository @Inject constructor(
}

fun pause() {
exoPlayer?.pause()
mediaController?.pause()
}

fun next() {
val player = exoPlayer ?: return
val player = mediaController ?: return
if (player.hasNextMediaItem()) {
player.seekToNext()
}
}

fun previous() {
val player = exoPlayer ?: return
val player = mediaController ?: return
if (player.hasPreviousMediaItem()) {
player.seekToPrevious()
}
}

fun seekTo(positionMs: Long) {
exoPlayer?.seekTo(positionMs)
mediaController?.seekTo(positionMs)
}

fun toggleShuffle() {
scope.launch {
withContext(Dispatchers.Main) {
val player = exoPlayer ?: return@withContext
val player = mediaController ?: return@withContext
player.shuffleModeEnabled = !player.shuffleModeEnabled
updateState()
}
Expand All @@ -381,7 +412,7 @@ class WearLocalPlayerRepository @Inject constructor(
fun cycleRepeat() {
scope.launch {
withContext(Dispatchers.Main) {
val player = exoPlayer ?: return@withContext
val player = mediaController ?: return@withContext
player.repeatMode = when (player.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
Expand All @@ -395,7 +426,7 @@ class WearLocalPlayerRepository @Inject constructor(
fun playQueueIndex(index: Int) {
scope.launch {
withContext(Dispatchers.Main) {
val player = exoPlayer ?: return@withContext
val player = mediaController ?: return@withContext
if (index !in 0 until player.mediaItemCount) return@withContext

player.seekToDefaultPosition(index)
Expand All @@ -413,7 +444,7 @@ class WearLocalPlayerRepository @Inject constructor(
val queueIndex = currentQueueSongIds.indexOf(songId)
if (queueIndex == -1) return@withContext

val player = exoPlayer
val player = mediaController
if (player == null || currentQueueSongIds.size <= 1) {
release()
return@withContext
Expand Down Expand Up @@ -441,10 +472,19 @@ class WearLocalPlayerRepository @Inject constructor(
*/
fun release() {
stopPositionUpdates()
mediaSession?.release()
mediaSession = null
exoPlayer?.release()
exoPlayer = null
mediaController?.let { controller ->
controller.removeListener(playerListener)
runCatching {
controller.stop()
controller.clearMediaItems()
}
controller.release()
}
mediaController = null
// Tear down the foreground service so its media notification clears immediately.
runCatching {
application.stopService(Intent(application, WearPlaybackService::class.java))
}
clearTransientPlaybackArtifacts()
_isLocalPlaybackActive.value = false
_localPlayerState.value = WearLocalPlayerState()
Expand All @@ -457,11 +497,11 @@ class WearLocalPlayerRepository @Inject constructor(
currentQueueItemsById = emptyMap()
lastPaletteSongId = ""
lastArtworkSongId = ""
Timber.tag(TAG).d("ExoPlayer released")
Timber.tag(TAG).d("MediaController released, WearPlaybackService stopped")
}

private fun updateState() {
val player = exoPlayer ?: return
val player = mediaController ?: return
val currentItem = player.currentMediaItem
val currentLocalSong = currentItem?.mediaId?.let(currentQueueSongsById::get)
_localPlayerState.value = WearLocalPlayerState(
Expand Down Expand Up @@ -516,8 +556,8 @@ class WearLocalPlayerRepository @Inject constructor(
}

private fun updateQueueState(currentIndex: Int? = null) {
val player = exoPlayer
val rawCurrentIndex = currentIndex ?: exoPlayer?.currentMediaItemIndex ?: -1
val player = mediaController
val rawCurrentIndex = currentIndex ?: mediaController?.currentMediaItemIndex ?: -1
val visibleQueueIndices = when {
player == null -> {
if (rawCurrentIndex in currentQueueSongIds.indices) {
Expand Down Expand Up @@ -577,7 +617,7 @@ class WearLocalPlayerRepository @Inject constructor(
)
}

private fun buildVisibleQueueIndices(player: ExoPlayer, currentIndex: Int): List<Int> {
private fun buildVisibleQueueIndices(player: Player, currentIndex: Int): List<Int> {
if (currentIndex !in 0 until player.mediaItemCount) {
return (0 until player.mediaItemCount).toList()
}
Expand Down
Loading