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