diff --git a/samples/powerplay/src/main/AndroidManifest.xml b/samples/powerplay/src/main/AndroidManifest.xml
index 9f629864d..cb21fed74 100644
--- a/samples/powerplay/src/main/AndroidManifest.xml
+++ b/samples/powerplay/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
playingSongIndex.intValue = pagerState.currentPage
- if (isPlaying.value) {
+ // Check the latest value of playerState state object
+ if (playerStateWrapper.value == PlayerState.Playing) {
player.startPlaying(
playingSongIndex.intValue,
OboePerformanceMode.fromInt(offload.intValue)
@@ -197,13 +254,40 @@ class MainActivity : ComponentActivity() {
}
}
-
-
Box(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.Center
) {
val configuration = LocalConfiguration.current
+ IconButton(
+ onClick = { showBottomSheet = true },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Performance Settings",
+ tint = Color.Black,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ IconButton(
+ onClick = { showInfoDialog = true },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = "Audio Info",
+ tint = Color.Black,
+ modifier = Modifier.size(32.dp)
+ )
+ }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AnimatedContent(targetState = playingSongIndex.intValue, transitionSpec = {
@@ -217,7 +301,7 @@ class MainActivity : ComponentActivity() {
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedContent(targetState = playingSongIndex.intValue, transitionSpec = {
- (scaleIn() + fadeIn()) with (scaleOut() + fadeOut())
+ (scaleIn() + fadeIn()).togetherWith(scaleOut() + fadeOut())
}, label = "") {
Text(
text = playList[it].artist, fontSize = 12.sp, color = Color.Black,
@@ -238,141 +322,11 @@ class MainActivity : ComponentActivity() {
) { page ->
val painter = painterResource(id = playList[page].cover)
if (page == pagerState.currentPage) {
- VinylAlbumCoverAnimation(isSongPlaying = isPlaying.value, painter = painter)
+ VinylAlbumCoverAnimation(isSongPlaying = isPlaying, painter = painter)
} else {
VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter)
}
}
- Spacer(modifier = Modifier.height(54.dp))
- Text(
- "Performance Modes"
- )
- Spacer(modifier = Modifier.height(8.dp))
-
- Column {
- val radioOptions = mutableListOf("None", "Low Latency", "Power Saving")
- if (isOffloadSupported) radioOptions.add("PCM Offload")
-
- val (selectedOption, onOptionSelected) = remember {
- mutableStateOf(radioOptions[0])
- }
- val enabled = !isPlaying.value
- radioOptions.forEachIndexed { index, text ->
- Row(
- Modifier
- .height(32.dp)
- .selectable(
- selected = (text == selectedOption),
- enabled = enabled,
- onClick = {
- if (enabled) {
- onOptionSelected(text)
- offload.intValue = index
- }
- },
- role = Role.RadioButton
- )
- .padding(horizontal = 4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RadioButton(
- selected = (text == selectedOption),
- onClick = null,
- enabled = enabled
- )
- Text(
- text = text,
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 8.dp)
- )
- }
- }
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- when (offload.intValue) {
- 0 -> "Performance Mode: None"
- 1 -> "Performance Mode: Low Latency"
- 2 -> "Performance Mode: Power Saving"
- else -> "Performance Mode: PCM Offload"
- }
- )
- Spacer(modifier = Modifier.height(8.dp))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 32.dp)
- .padding(vertical = 4.dp)
- ) {
- if (isMMapSupported) {
- Checkbox(
- checked = !isMMapEnabled.value,
- onCheckedChange = {
- if (!isPlaying.value) {
- isMMapEnabled.value = !it
- player.setMMapEnabled(isMMapEnabled.value)
- }
- },
- enabled = !isPlaying.value
- )
- Text(
- text = "Disable MMAP",
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 8.dp)
- )
-
- }
- Text(
- text = when (isMMapEnabled.value) {
- true -> "| Current Mode: MMAP"
- false -> "| Current Mode: Classic"
- },
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 8.dp)
- )
- }
- if (offload.intValue == 3) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 32.dp)
- .padding(top = 8.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- val requestedFrames = remember { mutableIntStateOf(0) }
- val actualFrames = remember { mutableIntStateOf(0) }
-
- Slider(
- value = sliderPosition,
- onValueChange = { newValue ->
- sliderPosition = newValue
- requestedFrames.intValue = sliderPosition.toInt()
- },
- onValueChangeFinished = {
- requestedFrames.intValue = sliderPosition.toInt()
- actualFrames.value = player.setBufferSizeInFrames(requestedFrames.intValue)
- },
- valueRange = 0f..player.getBufferCapacityInFrames().toFloat(),
- )
-
- val actualSeconds = actualFrames.value.toDouble() / sampleRate
- val formattedSeconds = "%.3f".format(actualSeconds)
-
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = "Requested: ${requestedFrames.intValue} Frames (BufferSize)",
- color = Color.Black,
- style = MaterialTheme.typography.bodySmall
- )
- Text(
- text = "Actual: ${actualFrames.value} Frames ($formattedSeconds seconds)",
- color = Color.Black,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- }
Spacer(modifier = Modifier.height(24.dp))
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -380,10 +334,10 @@ class MainActivity : ComponentActivity() {
) {
Spacer(modifier = Modifier.width(20.dp))
ControlButton(
- icon = if (isPlaying.value) R.drawable.ic_pause else R.drawable.ic_play,
+ icon = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
size = 100.dp,
onClick = {
- when (isPlaying.value) {
+ when (isPlaying) {
true -> player.stopPlaying(playingSongIndex.intValue)
false -> {
player.startPlaying(
@@ -392,14 +346,270 @@ class MainActivity : ComponentActivity() {
)
}
}
-
- isPlaying.value =
- player.getPlayerStateLive().value == PlayerState.Playing
})
Spacer(modifier = Modifier.width(20.dp))
}
}
}
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { },
+ sheetState = sheetState,
+ containerColor = Color.White,
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
+ ) {
+ PerformanceBottomSheetContent(
+ offload = offload,
+ isMMapEnabled = isMMapEnabled,
+ isPlaying = isPlaying,
+ sliderPosition = sliderPosition,
+ onSliderPositionChange = { sliderPosition = it },
+ onDismiss = { }
+ )
+ }
+ }
+
+ if (showInfoDialog) {
+ val performanceModeText = when (offload.intValue) {
+ 0 -> "None"
+ 1 -> "Low Latency"
+ 2 -> "Power Saving"
+ else -> "PCM Offload"
+ }
+ val mmapModeText = if (isMMapEnabled.value) "MMAP" else "Classic"
+ val bufferInfo = if (offload.intValue == 3) {
+ val bufferSeconds = sliderPosition.toDouble() / sampleRate
+ "%.3f seconds".format(bufferSeconds)
+ } else {
+ "N/A (not in PCM Offload mode)"
+ }
+
+ AlertDialog(
+ onDismissRequest = { },
+ title = {
+ Text(
+ text = "Audio Settings Info",
+ fontWeight = FontWeight.Bold
+ )
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row {
+ Text("Performance Mode: ", fontWeight = FontWeight.Medium)
+ Text(performanceModeText)
+ }
+ Row {
+ Text("Audio Mode: ", fontWeight = FontWeight.Medium)
+ Text(mmapModeText)
+ }
+ Row {
+ Text("Buffer Size: ", fontWeight = FontWeight.Medium)
+ Text(bufferInfo)
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { }) {
+ Text("Close")
+ }
+ },
+ containerColor = Color.White,
+ titleContentColor = Color.Black,
+ textContentColor = Color.Black
+ )
+ }
+ }
+
+ /**
+ * Bottom sheet content for Performance Modes settings
+ */
+ @Composable
+ fun PerformanceBottomSheetContent(
+ offload: androidx.compose.runtime.MutableIntState,
+ isMMapEnabled: androidx.compose.runtime.MutableState,
+ isPlaying: Boolean,
+ sliderPosition: Float,
+ onSliderPositionChange: (Float) -> Unit,
+ onDismiss: () -> Unit
+ ) {
+ var localSliderPosition by remember { mutableFloatStateOf(sliderPosition) }
+ val requestedFrames = remember { mutableIntStateOf(0) }
+ val actualFrames = remember { mutableIntStateOf(0) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Performance Modes",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Black,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ val radioOptions = mutableListOf("None", "Low Latency", "Power Saving")
+ if (isOffloadSupported) radioOptions.add("PCM Offload")
+
+ val (selectedOption, onOptionSelected) = remember {
+ mutableStateOf(radioOptions[offload.intValue])
+ }
+ val enabled = !isPlaying
+ radioOptions.forEachIndexed { index, text ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .selectable(
+ selected = (text == selectedOption),
+ enabled = enabled,
+ onClick = {
+ if (enabled) {
+ onOptionSelected(text)
+ player.updatePerformanceMode(OboePerformanceMode.fromInt(index))
+ offload.intValue = index
+ }
+ },
+ role = Role.RadioButton
+ )
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = (text == selectedOption),
+ onClick = null,
+ enabled = enabled,
+ colors = RadioButtonDefaults.colors(
+ selectedColor = MaterialTheme.colorScheme.primary
+ )
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = 12.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = when (offload.intValue) {
+ 0 -> "Performance Mode: None"
+ 1 -> "Performance Mode: Low Latency"
+ 2 -> "Performance Mode: Power Saving"
+ else -> "Performance Mode: PCM Offload"
+ },
+ color = Color.Gray,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isMMapSupported) {
+ Checkbox(
+ checked = !isMMapEnabled.value,
+ onCheckedChange = {
+ if (!isPlaying) {
+ isMMapEnabled.value = !it
+ player.setMMapEnabled(isMMapEnabled.value)
+ }
+ },
+ enabled = !isPlaying
+ )
+ Text(
+ text = "Disable MMAP",
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ Text(
+ text = when (isMMapEnabled.value) {
+ true -> "| Current Mode: MMAP"
+ false -> "| Current Mode: Classic"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.Gray,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = offload.intValue == 3,
+ enter = androidx.compose.animation.expandVertically() + fadeIn(),
+ exit = androidx.compose.animation.shrinkVertically() + fadeOut()
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Slider(
+ value = localSliderPosition,
+ onValueChange = { newValue ->
+ localSliderPosition = newValue
+ requestedFrames.intValue = localSliderPosition.toInt()
+ onSliderPositionChange(newValue)
+ },
+ onValueChangeFinished = {
+ requestedFrames.intValue = localSliderPosition.toInt()
+ actualFrames.intValue = player.setBufferSizeInFrames(requestedFrames.intValue)
+ },
+ valueRange = 0f..player.getBufferCapacityInFrames().toFloat(),
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ val actualSeconds = actualFrames.intValue.toDouble() / sampleRate
+ val formattedSeconds = "%.3f".format(actualSeconds)
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "Requested: ${requestedFrames.intValue} Frames (BufferSize)",
+ color = Color.Black,
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "Actual: ${actualFrames.intValue} Frames ($formattedSeconds seconds)",
+ color = Color.Black,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(Color(0xFFF0F0F0))
+ .clickable { onDismiss() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = Color.DarkGray,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
}
@Composable
@@ -423,7 +633,6 @@ class MainActivity : ComponentActivity() {
@Composable
fun VinylAlbumCoverAnimation(
- modifier: Modifier = Modifier,
isSongPlaying: Boolean = true,
painter: Painter
) {
@@ -434,7 +643,6 @@ class MainActivity : ComponentActivity() {
val rotation = remember {
Animatable(currentRotation)
}
-
LaunchedEffect(isSongPlaying) {
if (isSongPlaying) {
rotation.animateTo(
@@ -562,40 +770,4 @@ class MainActivity : ComponentActivity() {
return "$minutesString:$secondsString"
}
-
- /***
- * Return a play list of type Music data class
- */
- private fun getPlayList(): List {
- return listOf(
- Music(
- name = "Chemical Reaction",
- artist = "Momo Oboe",
- cover = R.drawable.album_art_1,
- fileName = "song1.wav",
- ),
- Music(
- name = "Digital Noca",
- artist = "Momo Oboe",
- cover = R.drawable.album_art_2,
- fileName = "song2.wav",
- ),
- Music(
- name = "Window Seat",
- artist = "Momo Oboe",
- cover = R.drawable.album_art_3,
- fileName = "song3.wav",
- ),
- )
- }
-
- /***
- * Data class to represent a music in the list
- */
- data class Music(
- val name: String,
- val artist: String,
- val fileName: String,
- val cover: Int,
- )
}
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MusicData.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MusicData.kt
new file mode 100644
index 000000000..12e3e73fd
--- /dev/null
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MusicData.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.oboe.samples.powerplay
+
+data class Music(
+ val name: String,
+ val artist: String,
+ val fileName: String,
+ val cover: Int,
+)
+
+val PlayList = listOf(
+ Music(
+ name = "Chemical Reaction",
+ artist = "Momo Oboe",
+ cover = R.drawable.album_art_1,
+ fileName = "song1.wav",
+ ),
+ Music(
+ name = "Digital Noca",
+ artist = "Momo Oboe",
+ cover = R.drawable.album_art_2,
+ fileName = "song2.wav",
+ ),
+ Music(
+ name = "Window Seat",
+ artist = "Momo Oboe",
+ cover = R.drawable.album_art_3,
+ fileName = "song3.wav",
+ ),
+)
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt
index 2dbc9371a..54ef39831 100644
--- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt
@@ -23,55 +23,138 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Binder
import android.os.IBinder
+import android.os.PowerManager
import android.util.Log
-import androidx.core.app.NotificationCompat
+import androidx.lifecycle.Observer
import com.google.oboe.samples.powerplay.MainActivity
+import com.google.oboe.samples.powerplay.PlayList
import com.google.oboe.samples.powerplay.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
class AudioForegroundService : Service() {
private lateinit var audioManager: AudioManager
private lateinit var audioFocusRequest: AudioFocusRequest
+ private lateinit var mediaSession: MediaSession
+ private lateinit var wakeLock: PowerManager.WakeLock
+ private var currentAlbumArt: Bitmap? = null
+
+ lateinit var player: PowerPlayAudioPlayer
+ private val binder = LocalBinder()
+
+ private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
+ private var playbackJob: Job? = null
+
+ inner class LocalBinder : Binder() {
+ fun getService(): AudioForegroundService = this@AudioForegroundService
+ }
+
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
-
+ if (::player.isInitialized) {
+ player.startPlaying(player.currentSongIndex, null)
+ }
}
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
- // TODO Handle loss of audio focus
+ if (::player.isInitialized) {
+ player.stopPlaying(player.currentSongIndex)
+ }
}
}
}
+ private val songIndexObserver = Observer { index ->
+ loadAlbumArt(index)
+ }
+
override fun onCreate() {
super.onCreate()
- audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
- val audioAttributes = AudioAttributes.Builder()
+ try {
+ player = PowerPlayAudioPlayer()
+ player.setupAudioStream()
+
+ audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
- audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
+
+ mediaSession = MediaSession(this, "PowerPlayAudioService").apply {
+ setCallback(object : MediaSession.Callback() {
+ override fun onPlay() {
+ if (::player.isInitialized) player.startPlaying(player.currentSongIndex, player.currentPerformanceMode)
+ }
+
+ override fun onPause() {
+ if (::player.isInitialized) player.stopPlaying(player.currentSongIndex)
+ }
+
+ override fun onStop() {
+ if (::player.isInitialized) player.stopPlaying(player.currentSongIndex)
+ stopSelf()
+ }
+ })
+ setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
+ isActive = true
+ }
+
+ player.getCurrentSongIndexLive().observeForever(songIndexObserver)
+
+ val powerManager = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PowerPlay::AudioWakeLock")
+ } catch (e: Throwable) {
+ Log.e(TAG, "Error in onCreate", e)
+ throw RuntimeException("Failed to create AudioForegroundService", e)
+ }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val notification = createNotification()
- startForeground(NOTIFICATION_ID, notification)
-
- val result = audioManager.requestAudioFocus(audioFocusRequest)
- if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
- // TODO tart playback only when audio focus is granted
- } else {
- Log.e(TAG, "Failed to get audio focus, result: $result")
+ try {
+ val notification = createNotification()
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ )
+
+ val result = audioManager.requestAudioFocus(audioFocusRequest)
+ if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+ // Let UI control playback start
+ } else {
+ Log.e(TAG, "Failed to get audio focus, result: $result")
+ }
+
+ if (!wakeLock.isHeld) {
+ wakeLock.acquire(10*60*1000L /*10 minutes*/)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, "Error in onStartCommand", e)
+ stopSelf()
}
return START_STICKY
@@ -79,13 +162,62 @@ class AudioForegroundService : Service() {
override fun onDestroy() {
super.onDestroy()
- audioManager.abandonAudioFocusRequest(audioFocusRequest)
+ if (::player.isInitialized) {
+ player.getCurrentSongIndexLive().removeObserver(songIndexObserver)
+ player.stopPlaying(player.currentSongIndex)
+ player.teardownAudioStream()
+ }
+
+ if (::audioManager.isInitialized) {
+ audioManager.abandonAudioFocusRequest(audioFocusRequest)
+ }
+ if (::mediaSession.isInitialized) {
+ mediaSession.release()
+ }
+ if (::wakeLock.isInitialized && wakeLock.isHeld) {
+ wakeLock.release()
+ }
+ }
+
+ private fun loadAlbumArt(index: Int) {
+ serviceScope.launch(Dispatchers.IO) {
+ val song = PlayList.getOrNull(index)
+ val bitmap = song?.let { BitmapFactory.decodeResource(resources, it.cover) }
+ withContext(Dispatchers.Main) {
+ currentAlbumArt = bitmap
+ updateMetadata()
+ updateNotification()
+ }
+ }
+ }
+
+ private fun updateMetadata() {
+ if (!::player.isInitialized || !::mediaSession.isInitialized) return
+
+ val currentSong = PlayList.getOrNull(player.currentSongIndex)
+ val songTitle = currentSong?.name ?: "PowerPlay Audio"
+ val songArtist = currentSong?.artist ?: "Playing..."
+
+ val metadataBuilder = MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle)
+ .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist)
+
+ if (currentAlbumArt != null) {
+ metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt)
+ }
+
+ mediaSession.setMetadata(metadataBuilder.build())
+ }
+
+ private fun updateNotification() {
+ val notification = createNotification()
+ val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun createNotification(): Notification {
val channelId = createNotificationChannel()
- val notificationIntent =
- Intent(this, MainActivity::class.java)
+ val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
@@ -93,13 +225,37 @@ class AudioForegroundService : Service() {
PendingIntent.FLAG_IMMUTABLE
)
- return NotificationCompat.Builder(this, channelId)
- .setContentTitle("Audio Playing")
- .setContentText("Audio is playing in the background")
- .setSmallIcon(R.drawable.ic_play) // Replace with your icon
+ val isPlaying = if (::player.isInitialized) {
+ player.getPlayerStateLive().value == PlayerState.Playing
+ } else false
+
+ val style = android.app.Notification.MediaStyle()
+ .setMediaSession(mediaSession.sessionToken)
+
+ val currentSong = if (::player.isInitialized) PlayList.getOrNull(player.currentSongIndex) else null
+ val songTitle = currentSong?.name ?: "PowerPlay Audio"
+ val songArtist = currentSong?.artist ?: (if (isPlaying) "Playing" else "Paused")
+
+ val builder = Notification.Builder(this, channelId)
+ .setContentTitle(songTitle)
+ .setContentText(songArtist)
+ .setSmallIcon(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play)
.setContentIntent(pendingIntent)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
+ .setStyle(style)
+
+ if (currentAlbumArt != null) {
+ builder.setLargeIcon(currentAlbumArt)
+ }
+
+ if (isPlaying) {
+ val pauseIntent = PendingIntent.getService(
+ this, 1,
+ Intent(this, AudioForegroundService::class.java).apply { action = "PAUSE" },
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ return builder.build()
}
private fun createNotificationChannel(): String {
@@ -114,7 +270,7 @@ class AudioForegroundService : Service() {
}
override fun onBind(intent: Intent?): IBinder? {
- return null
+ return binder
}
companion object {
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/OboePerformanceMode.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/OboePerformanceMode.kt
index 63a51d5fe..f28fbe6b2 100644
--- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/OboePerformanceMode.kt
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/OboePerformanceMode.kt
@@ -47,6 +47,6 @@ enum class OboePerformanceMode(val value: Int) {
PowerSavingOffloaded(3); // AAUDIO_PERFORMANCE_MODE_POWER_SAVING_OFFLOADED
companion object {
- fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
+ fun fromInt(value: Int) = entries.first { it.value == value }
}
}
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
index 329e6cdbf..7249b1586 100644
--- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
@@ -26,6 +26,13 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
private var _playerState = MutableStateFlow(PlayerState.NoResultYet)
fun getPlayerStateLive() = _playerState.asLiveData()
+ private var _currentSongIndex = MutableStateFlow(0)
+ fun getCurrentSongIndexLive() = _currentSongIndex.asLiveData()
+ val currentSongIndex: Int get() = _currentSongIndex.value
+
+ private var _currentPerformanceMode = OboePerformanceMode.None
+ val currentPerformanceMode: OboePerformanceMode get() = _currentPerformanceMode
+
/**
* Native passthrough functions
*/
@@ -35,7 +42,11 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
}
fun startPlaying(index: Int, mode: OboePerformanceMode?) {
- startPlayingNative(index, mode ?: OboePerformanceMode.None)
+ val actualMode = mode ?: _currentPerformanceMode
+ _currentPerformanceMode = actualMode
+ _currentSongIndex.update { index }
+
+ startPlayingNative(index, actualMode)
_playerState.update { PlayerState.Playing }
}
@@ -44,6 +55,11 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
_playerState.update { PlayerState.Stopped }
}
+ fun updatePerformanceMode(mode: OboePerformanceMode) {
+ _currentPerformanceMode = mode
+ updatePerformanceModeNative(mode)
+ }
+
fun setLooping(index: Int, looping: Boolean) = setLoopingNative(index, looping)
fun teardownAudioStream() = teardownAudioStreamNative()
fun unloadAssets() = unloadAssetsNative()
@@ -111,6 +127,7 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
private external fun setLoopingNative(index: Int, looping: Boolean)
private external fun startPlayingNative(index: Int, mode: OboePerformanceMode)
private external fun stopPlayingNative(index: Int)
+ private external fun updatePerformanceModeNative(mode: OboePerformanceMode)
private external fun setMMapEnabledNative(enabled: Boolean): Boolean
private external fun isMMapEnabledNative(): Boolean
private external fun isMMapSupportedNative(): Boolean