From c1503ead592015072ee69d8b26aa82deac9f5c1c Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 26 Jan 2026 18:38:45 -0500 Subject: [PATCH 01/12] feat. New Player UI + Setting for PCM Offload --- .../iolib/src/main/cpp/player/SampleSource.h | 8 + .../powerplay/src/main/AndroidManifest.xml | 1 + .../powerplay/src/main/cpp/PowerPlayJNI.cpp | 34 ++- .../src/main/cpp/PowerPlayMultiPlayer.cpp | 30 ++ .../src/main/cpp/PowerPlayMultiPlayer.h | 6 + .../src/main/cpp/PowerPlaySampleSource.h | 42 +++ .../oboe/samples/powerplay/MainActivity.kt | 144 ++++++---- .../oboe/samples/powerplay/MusicData.kt | 44 +++ .../engine/AudioForegroundService.kt | 257 ++++++++++++++++-- .../powerplay/engine/PowerPlayAudioPlayer.kt | 20 +- 10 files changed, 503 insertions(+), 83 deletions(-) create mode 100644 samples/powerplay/src/main/cpp/PowerPlaySampleSource.h create mode 100644 samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MusicData.kt diff --git a/samples/iolib/src/main/cpp/player/SampleSource.h b/samples/iolib/src/main/cpp/player/SampleSource.h index f73034263..f03d075bb 100644 --- a/samples/iolib/src/main/cpp/player/SampleSource.h +++ b/samples/iolib/src/main/cpp/player/SampleSource.h @@ -18,6 +18,7 @@ #define _PLAYER_SAMPLESOURCE_ #include +#include #include "DataSource.h" @@ -63,6 +64,13 @@ class SampleSource: public DataSource { int32_t getPlayHeadPosition() const { return mCurSampleIndex; } + int32_t getNumFrames() const { + if (mSampleBuffer == nullptr) return 0; + int32_t channels = mSampleBuffer->getProperties().channelCount; + if (channels <= 0) return 0; + return mSampleBuffer->getNumSamples() / channels; + } + void setPan(float pan) { if (pan < PAN_HARDLEFT) { mPan = PAN_HARDLEFT; 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 @@ + #include "PowerPlayMultiPlayer.h" +#include "PowerPlaySampleSource.h" static const char *TAG = "PowerPlayJNI"; @@ -153,7 +154,7 @@ JNIEXPORT void JNICALL Java_com_google_oboe_samples_powerplay_engine_PowerPlayAu auto *sampleBuffer = new SampleBuffer(); sampleBuffer->loadSampleData(&reader); - const auto source = new OneShotSampleSource(sampleBuffer, 0); + const auto source = new PowerPlaySampleSource(sampleBuffer, 0); player.addSampleSource(source, sampleBuffer); delete[] buf; @@ -275,6 +276,37 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getBufferCapa return player.getBufferCapacityInFrames(); } +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.getCurrentPositionNative() + */ +JNIEXPORT jint JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentPositionNative( + JNIEnv *, + jobject) { + return player.getCurrentPosition(); +} + +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.getDurationNative() + */ +JNIEXPORT jint JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getDurationNative( + JNIEnv *, + jobject) { + return player.getDuration(); +} + +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.seekToNative() + */ +JNIEXPORT void JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_seekToNative( + JNIEnv *, + jobject, + jint positionFrames) { + player.seekTo(positionFrames); +} + #ifdef __cplusplus } #endif diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index 3cb50fa23..d7077c955 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -15,6 +15,7 @@ */ #include #include "PowerPlayMultiPlayer.h" +#include "PowerPlaySampleSource.h" static const char *TAG = "PowerPlayMultiPlayer"; @@ -239,3 +240,32 @@ int32_t PowerPlayMultiPlayer::getBufferCapacityInFrames() { return mAudioStream->getBufferCapacityInFrames(); } + +int32_t PowerPlayMultiPlayer::getCurrentPosition() { + int32_t index = getCurrentlyPlayingIndex(); + if (index != -1) { + auto source = dynamic_cast(mSampleSources[index]); + if (source) { + return source->getPositionInFrames(); + } + } + return 0; +} + +int32_t PowerPlayMultiPlayer::getDuration() { + int32_t index = getCurrentlyPlayingIndex(); + if (index != -1) { + return mSampleSources[index]->getNumFrames(); + } + return 0; +} + +void PowerPlayMultiPlayer::seekTo(int32_t positionFrames) { + int32_t index = getCurrentlyPlayingIndex(); + if (index != -1) { + auto source = dynamic_cast(mSampleSources[index]); + if (source) { + source->setPositionInFrames(positionFrames); + } + } +} diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index 319f71442..7b3bb122e 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -50,6 +50,12 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { int32_t getBufferCapacityInFrames(); + int32_t getCurrentPosition(); + + int32_t getDuration(); + + void seekTo(int32_t positionFrames); + private: class MyPresentationCallback : public oboe::AudioStreamPresentationCallback { public: diff --git a/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h b/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h new file mode 100644 index 000000000..40796e100 --- /dev/null +++ b/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h @@ -0,0 +1,42 @@ +/* + * 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. + */ + +#ifndef SAMPLES_POWERPLAYSAMPLESOURCE_H +#define SAMPLES_POWERPLAYSAMPLESOURCE_H + +#include + +class PowerPlaySampleSource : public iolib::OneShotSampleSource { +public: + using OneShotSampleSource::OneShotSampleSource; + + int32_t getPositionInFrames() const { + if (mSampleBuffer == nullptr) return 0; + int32_t channels = mSampleBuffer->getProperties().channelCount; + if (channels <= 0) return 0; + return mCurSampleIndex / channels; + } + + void setPositionInFrames(int32_t frameIndex) { + if (mSampleBuffer == nullptr) return; + int32_t channels = mSampleBuffer->getProperties().channelCount; + mCurSampleIndex = frameIndex * channels; + if (mCurSampleIndex < 0) mCurSampleIndex = 0; + if (mCurSampleIndex > mSampleBuffer->getNumSamples()) mCurSampleIndex = mSampleBuffer->getNumSamples(); + } +}; + +#endif //SAMPLES_POWERPLAYSAMPLESOURCE_H diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index ce42f9dc2..365e2bc1a 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -16,11 +16,15 @@ package com.google.oboe.samples.powerplay +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager import android.os.Bundle +import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedContent @@ -57,6 +61,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton @@ -66,6 +71,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -100,6 +106,7 @@ import com.google.oboe.samples.powerplay.engine.OboePerformanceMode import com.google.oboe.samples.powerplay.engine.PlayerState import com.google.oboe.samples.powerplay.engine.PowerPlayAudioPlayer import com.google.oboe.samples.powerplay.ui.theme.MusicPlayerTheme +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged class MainActivity : ComponentActivity() { @@ -108,7 +115,22 @@ class MainActivity : ComponentActivity() { private lateinit var serviceIntent: Intent private var isMMapSupported: Boolean = false private var isOffloadSupported: Boolean = false - private var sampleRate: Int = 48000; + private var sampleRate: Int = 48000 + private var isBound = mutableStateOf(false) + + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as AudioForegroundService.LocalBinder + player = binder.getService().player + // Re-check support flags as player is now available + isMMapSupported = player.isMMapSupported() + isBound.value = true + } + + override fun onServiceDisconnected(arg0: ComponentName) { + isBound.value = false + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -128,7 +150,7 @@ class MainActivity : ComponentActivity() { serviceIntent = Intent(this, AudioForegroundService::class.java) isOffloadSupported = AudioManager.isOffloadedPlaybackSupported(format, attributes) - isMMapSupported = player.isMMapSupported() + // isMMapSupported checked in connection callback setContent { MusicPlayerTheme { @@ -136,7 +158,13 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - SongScreen() + if (isBound.value) { + SongScreen() + } else { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator() + } + } } } } @@ -144,13 +172,17 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - player.stopPlaying(0) - player.teardownAudioStream() + if (isBound.value) { + unbindService(connection) + isBound.value = false + } + // Player lifecycle is now managed by Service } private fun setUpPowerPlayAudioPlayer() { - player = PowerPlayAudioPlayer() - player.setupAudioStream() + val intent = Intent(this, AudioForegroundService::class.java) + startForegroundService(intent) // Starts the service + bindService(intent, connection, Context.BIND_AUTO_CREATE) } /*** @@ -160,7 +192,7 @@ class MainActivity : ComponentActivity() { @Preview @Composable fun SongScreen() { - val playList = getPlayList() + val playList = PlayList val pagerState = rememberPagerState(pageCount = { playList.count() }) val playingSongIndex = remember { mutableIntStateOf(0) @@ -170,18 +202,30 @@ class MainActivity : ComponentActivity() { } val isMMapEnabled = remember { mutableStateOf(player.isMMapEnabled()) } - val isPlaying = remember { - mutableStateOf(false) - } + + val playerState by player.getPlayerStateLive().observeAsState(PlayerState.NoResultYet) + val isPlaying = playerState == PlayerState.Playing + var sliderPosition by remember { mutableFloatStateOf(0f) } - + var currentPosition by remember { mutableFloatStateOf(0f) } + var duration by remember { mutableFloatStateOf(1f) } + + // Poll for playback progress + LaunchedEffect(isPlaying) { + while (isPlaying) { + currentPosition = player.getCurrentPosition().toFloat() + val dur = player.getDuration() + if (dur > 0) duration = dur.toFloat() + delay(100) + } + } LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage } .distinctUntilChanged() .collect { page -> playingSongIndex.intValue = pagerState.currentPage - if (isPlaying.value) { + if (isPlaying) { player.startPlaying( playingSongIndex.intValue, OboePerformanceMode.fromInt(offload.intValue) @@ -238,12 +282,33 @@ 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)) + Spacer(modifier = Modifier.height(24.dp)) + + // Song Progress Slider + Column(modifier = Modifier.padding(horizontal = 32.dp)) { + Slider( + value = currentPosition, + valueRange = 0f..duration, + onValueChange = { + currentPosition = it + player.seekTo(it.toInt()) + } + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = (currentPosition.toLong() * 1000 / sampleRate).convertToText()) + Text(text = (duration.toLong() * 1000 / sampleRate).convertToText()) + } + } + + Spacer(modifier = Modifier.height(24.dp)) Text( "Performance Modes" ) @@ -256,7 +321,7 @@ class MainActivity : ComponentActivity() { val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[0]) } - val enabled = !isPlaying.value + val enabled = !isPlaying radioOptions.forEachIndexed { index, text -> Row( Modifier @@ -309,12 +374,12 @@ class MainActivity : ComponentActivity() { Checkbox( checked = !isMMapEnabled.value, onCheckedChange = { - if (!isPlaying.value) { + if (!isPlaying) { isMMapEnabled.value = !it player.setMMapEnabled(isMMapEnabled.value) } }, - enabled = !isPlaying.value + enabled = !isPlaying ) Text( text = "Disable MMAP", @@ -380,10 +445,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( @@ -393,8 +458,7 @@ class MainActivity : ComponentActivity() { } } - isPlaying.value = - player.getPlayerStateLive().value == PlayerState.Playing + // isPlaying is now observed from flow }) Spacer(modifier = Modifier.width(20.dp)) } @@ -562,40 +626,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..7cd48dcad 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,155 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +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.Build 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 class AudioForegroundService : Service() { private lateinit var audioManager: AudioManager private lateinit var audioFocusRequest: AudioFocusRequest + private lateinit var mediaSession: MediaSession + private lateinit var wakeLock: PowerManager.WakeLock + + 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 playerStateObserver = Observer { state -> + updatePlaybackState() + // Update notification to reflect Play/Pause icon if needed + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + + if (state == PlayerState.Playing) { + startProgressUpdater() + } else { + stopProgressUpdater() + } + } + + private val songIndexObserver = Observer { index -> + updatePlaybackState() + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + 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 + } + + // Observe player state + player.getPlayerStateLive().observeForever(playerStateObserver) + 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) + 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) { - // TODO tart playback only when audio focus is granted - } else { - Log.e(TAG, "Failed to get audio focus, result: $result") + 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() + } + } catch (e: Throwable) { + Log.e(TAG, "Error in onStartCommand", e) + stopSelf() } return START_STICKY @@ -79,13 +179,93 @@ class AudioForegroundService : Service() { override fun onDestroy() { super.onDestroy() - audioManager.abandonAudioFocusRequest(audioFocusRequest) + if (::player.isInitialized) { + player.getPlayerStateLive().removeObserver(playerStateObserver) + player.getCurrentSongIndexLive().removeObserver(songIndexObserver) + player.stopPlaying(player.currentSongIndex) + player.teardownAudioStream() + } + stopProgressUpdater() + // Cancel service scope + // serviceScope.cancel() // Actually Job() inside, maybe just cancel the job. + // Or if I used MainScope(), cancel it. + // But since it's a Service, canceling scope is good. + + if (::audioManager.isInitialized) { + audioManager.abandonAudioFocusRequest(audioFocusRequest) + } + if (::mediaSession.isInitialized) { + mediaSession.release() + } + if (::wakeLock.isInitialized && wakeLock.isHeld) { + wakeLock.release() + } + } + + private fun startProgressUpdater() { + playbackJob?.cancel() + playbackJob = serviceScope.launch { + while (isActive) { + updatePlaybackState() + delay(1000) // Update every second + } + } + } + + private fun stopProgressUpdater() { + playbackJob?.cancel() + playbackJob = null + updatePlaybackState() // Update one last time for final state + } + + private fun updatePlaybackState() { + if (!::player.isInitialized || !::mediaSession.isInitialized) return + + val state = player.getPlayerStateLive().value + val position = player.getCurrentPosition().toLong() // frames + val sampleRate = 48000L + val positionMs = (position * 1000) / sampleRate + + val playbackStateBuilder = PlaybackState.Builder() + + if (state == PlayerState.Playing) { + playbackStateBuilder.setState(PlaybackState.STATE_PLAYING, positionMs, 1.0f) + } else { + playbackStateBuilder.setState(PlaybackState.STATE_PAUSED, positionMs, 0.0f) + } + + playbackStateBuilder.setActions( + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SEEK_TO + ) + + mediaSession.setPlaybackState(playbackStateBuilder.build()) + + val durationFrames = player.getDuration().toLong() + val durationMs = (durationFrames * 1000) / sampleRate + + val currentSong = PlayList.getOrNull(player.currentSongIndex) + val songTitle = currentSong?.name ?: "PowerPlay Audio" + val songArtist = currentSong?.artist ?: "Playing..." + + val metadataBuilder = MediaMetadata.Builder() + .putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) + .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) + .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) + + if (currentSong != null) { + val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) + } + + mediaSession.setMetadata(metadataBuilder.build()) } 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 +273,44 @@ 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 + // Update notification icon based on state + 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 (currentSong != null) { + val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) + builder.setLargeIcon(bitmap) + } + + // Add Play/Pause action + if (isPlaying) { + val pauseIntent = PendingIntent.getService( + this, 1, + Intent(this, AudioForegroundService::class.java).apply { action = "PAUSE" }, + PendingIntent.FLAG_IMMUTABLE + ) + // We'd need to handle this intent in onStartCommand if we added buttons here. + // For now, relying on MediaSession transport controls is implicit for some Android versions/devices + // but strictly speaking for Notification.MediaStyle, we usually add actions. + // Since I didn't implement onStartCommand action handling, I'll rely on MediaSession callback. + } + + return builder.build() } private fun createNotificationChannel(): String { @@ -114,7 +325,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/PowerPlayAudioPlayer.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt index 329e6cdbf..99228cf7c 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 } } @@ -93,6 +104,10 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { fun getBufferCapacityInFrames(): Int = getBufferCapacityInFramesNative() + fun getCurrentPosition(): Int = getCurrentPositionNative() + fun getDuration(): Int = getDurationNative() + fun seekTo(positionFrames: Int) = seekToNative(positionFrames) + /** * Native functions. * Load the library containing the native code including the JNI functions. @@ -116,6 +131,9 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { private external fun isMMapSupportedNative(): Boolean private external fun setBufferSizeInFramesNative(bufferSizeInFrames: Int): Int private external fun getBufferCapacityInFramesNative(): Int + private external fun getCurrentPositionNative(): Int + private external fun getDurationNative(): Int + private external fun seekToNative(positionFrames: Int) /** * Companion From 598dc6868bac25148e5a08aa9a09b490eddfc3fa Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 26 Jan 2026 18:57:11 -0500 Subject: [PATCH 02/12] Fix AudioService Blocking the main thread, causing UI slowdowns --- .../engine/AudioForegroundService.kt | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) 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 7cd48dcad..d6cd0376e 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 @@ -24,6 +24,7 @@ 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 @@ -46,6 +47,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AudioForegroundService : Service() { @@ -53,6 +55,7 @@ class AudioForegroundService : Service() { 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() @@ -82,10 +85,7 @@ class AudioForegroundService : Service() { private val playerStateObserver = Observer { state -> updatePlaybackState() - // Update notification to reflect Play/Pause icon if needed - val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, notification) + updateNotification() if (state == PlayerState.Playing) { startProgressUpdater() @@ -95,10 +95,7 @@ class AudioForegroundService : Service() { } private val songIndexObserver = Observer { index -> - updatePlaybackState() - val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, notification) + loadAlbumArt(index) } override fun onCreate() { @@ -202,6 +199,18 @@ class AudioForegroundService : Service() { } } + 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 startProgressUpdater() { playbackJob?.cancel() playbackJob = serviceScope.launch { @@ -242,7 +251,12 @@ class AudioForegroundService : Service() { ) mediaSession.setPlaybackState(playbackStateBuilder.build()) + } + private fun updateMetadata() { + if (!::player.isInitialized || !::mediaSession.isInitialized) return + + val sampleRate = 48000L val durationFrames = player.getDuration().toLong() val durationMs = (durationFrames * 1000) / sampleRate @@ -255,14 +269,19 @@ class AudioForegroundService : Service() { .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) - if (currentSong != null) { - val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) - metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) + if (currentAlbumArt != null) { + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt) } mediaSession.setMetadata(metadataBuilder.build()) } + private fun updateNotification() { + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + private fun createNotification(): Notification { val channelId = createNotificationChannel() val notificationIntent = Intent(this, MainActivity::class.java) @@ -292,9 +311,8 @@ class AudioForegroundService : Service() { .setContentIntent(pendingIntent) .setStyle(style) - if (currentSong != null) { - val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) - builder.setLargeIcon(bitmap) + if (currentAlbumArt != null) { + builder.setLargeIcon(currentAlbumArt) } // Add Play/Pause action From 39eb164cc72a7dacdc37c812543d4a8c16d32546 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 26 Jan 2026 19:03:28 -0500 Subject: [PATCH 03/12] fix issue where frames were not being reported correctly when the user switched to PCM offload mode --- .../src/main/cpp/PowerPlayMultiPlayer.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index d7077c955..78ce91d73 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -246,7 +246,23 @@ int32_t PowerPlayMultiPlayer::getCurrentPosition() { if (index != -1) { auto source = dynamic_cast(mSampleSources[index]); if (source) { - return source->getPositionInFrames(); + int64_t sourceFrameIndex = source->getPositionInFrames(); + + if (mAudioStream != nullptr) { + int64_t framesWritten = mAudioStream->getFramesWritten(); + int64_t framesPresented = 0; + int64_t presentationTimeNs = 0; + + auto result = mAudioStream->getTimestamp(CLOCK_MONOTONIC, &framesPresented, &presentationTimeNs); + + if (result == Result::OK) { + int64_t latencyFrames = framesWritten - framesPresented; + int64_t playhead = sourceFrameIndex - latencyFrames; + if (playhead < 0) playhead = 0; + return (int32_t)playhead; + } + } + return (int32_t)sourceFrameIndex; } } return 0; From 6f899037bf90b4cb0221093c754f4a44fae9c150 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 13:33:03 -0500 Subject: [PATCH 04/12] PowerPlay: Add dynamic performance mode switching --- .../powerplay/src/main/cpp/PowerPlayJNI.cpp | 12 +++++ .../src/main/cpp/PowerPlayMultiPlayer.cpp | 40 +++++++++------- .../src/main/cpp/PowerPlayMultiPlayer.h | 2 + .../oboe/samples/powerplay/MainActivity.kt | 1 + .../engine/AudioForegroundService.kt | 46 ++++++------------- .../powerplay/engine/OboePerformanceMode.kt | 2 +- .../powerplay/engine/PowerPlayAudioPlayer.kt | 6 +++ 7 files changed, 59 insertions(+), 50 deletions(-) diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp index fefa5579e..ad46e6514 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp @@ -212,6 +212,18 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_stopPlayingNa player.triggerUp(index); } +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.updatePerformanceModeNative() + */ +JNIEXPORT void JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_updatePerformanceModeNative( + JNIEnv *env, + jobject, + jobject mode) { + auto performanceMode = getPerformanceMode(env, mode); + player.updatePerformanceMode(performanceMode); +} + /** * Native (JNI) implementation of PowerPlayAudioPlayer.setLoopingNative() */ diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index 78ce91d73..825eccdc6 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -14,6 +14,7 @@ * limitations under the License. */ #include +#include #include "PowerPlayMultiPlayer.h" #include "PowerPlaySampleSource.h" @@ -133,22 +134,7 @@ void PowerPlayMultiPlayer::triggerDown(int32_t index, oboe::PerformanceMode perf return; } - // If the performance mode has changed, we need to reopen the stream. - // Also reopen if the user has changed the MMAP policy (enabled/disabled) since the stream was opened. - if (performanceMode != mLastPerformanceMode || - isMMapEnabled() != mLastMMapEnabled) { - teardownAudioStream(); - - // Attempt here to reopen the stream with the new performance mode. - const auto result = openStream(performanceMode); - if (!result) { - // Something went wrong and the stream could not be reopened. - __android_log_print(ANDROID_LOG_ERROR, - TAG, - "Failed to reopen stream with new performance mode"); - return; - } - } + updatePerformanceMode(performanceMode); // Assure previous sample is stopped and the play head is reset to zero, avoiding the // currently playing index. Only allow the playback head to reset when the song has changed. @@ -185,6 +171,16 @@ void PowerPlayMultiPlayer::triggerDown(int32_t index, oboe::PerformanceMode perf } } +void PowerPlayMultiPlayer::updatePerformanceMode(oboe::PerformanceMode performanceMode) { + if (performanceMode != mLastPerformanceMode || + isMMapEnabled() != mLastMMapEnabled) { + + __android_log_print(ANDROID_LOG_INFO, TAG, "updatePerformanceMode: Reopening stream"); + teardownAudioStream(); + openStream(performanceMode); + } +} + bool PowerPlayMultiPlayer::setMMapEnabled(bool enabled) { auto result = oboe::OboeExtensions::setMMapEnabled(enabled); return result == 0; @@ -256,7 +252,17 @@ int32_t PowerPlayMultiPlayer::getCurrentPosition() { auto result = mAudioStream->getTimestamp(CLOCK_MONOTONIC, &framesPresented, &presentationTimeNs); if (result == Result::OK) { - int64_t latencyFrames = framesWritten - framesPresented; + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + int64_t nowNs = (now.tv_sec * 1000000000LL) + now.tv_nsec; + + int64_t deltaNs = nowNs - presentationTimeNs; + if (deltaNs < 0) deltaNs = 0; + + int64_t deltaFrames = (deltaNs * mAudioStream->getSampleRate()) / 1000000000LL; + int64_t currentFramesPresented = framesPresented + deltaFrames; + + int64_t latencyFrames = framesWritten - currentFramesPresented; int64_t playhead = sourceFrameIndex - latencyFrames; if (playhead < 0) playhead = 0; return (int32_t)playhead; diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index 7b3bb122e..ed4d3c69d 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -36,6 +36,8 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { void triggerDown(int32_t index, oboe::PerformanceMode performanceMode) override; + void updatePerformanceMode(oboe::PerformanceMode performanceMode); + static bool setMMapEnabled(bool enabled); static bool isMMapEnabled(); diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index 365e2bc1a..ad1053431 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -332,6 +332,7 @@ class MainActivity : ComponentActivity() { onClick = { if (enabled) { onOptionSelected(text) + player.updatePerformanceMode(OboePerformanceMode.fromInt(index)) offload.intValue = index } }, 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 d6cd0376e..7cd48dcad 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 @@ -24,7 +24,6 @@ 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 @@ -47,7 +46,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class AudioForegroundService : Service() { @@ -55,7 +53,6 @@ class AudioForegroundService : Service() { 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() @@ -85,7 +82,10 @@ class AudioForegroundService : Service() { private val playerStateObserver = Observer { state -> updatePlaybackState() - updateNotification() + // Update notification to reflect Play/Pause icon if needed + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) if (state == PlayerState.Playing) { startProgressUpdater() @@ -95,7 +95,10 @@ class AudioForegroundService : Service() { } private val songIndexObserver = Observer { index -> - loadAlbumArt(index) + updatePlaybackState() + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) } override fun onCreate() { @@ -199,18 +202,6 @@ class AudioForegroundService : Service() { } } - 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 startProgressUpdater() { playbackJob?.cancel() playbackJob = serviceScope.launch { @@ -251,12 +242,7 @@ class AudioForegroundService : Service() { ) mediaSession.setPlaybackState(playbackStateBuilder.build()) - } - private fun updateMetadata() { - if (!::player.isInitialized || !::mediaSession.isInitialized) return - - val sampleRate = 48000L val durationFrames = player.getDuration().toLong() val durationMs = (durationFrames * 1000) / sampleRate @@ -269,19 +255,14 @@ class AudioForegroundService : Service() { .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) - if (currentAlbumArt != null) { - metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt) + if (currentSong != null) { + val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) } mediaSession.setMetadata(metadataBuilder.build()) } - private fun updateNotification() { - val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, notification) - } - private fun createNotification(): Notification { val channelId = createNotificationChannel() val notificationIntent = Intent(this, MainActivity::class.java) @@ -311,8 +292,9 @@ class AudioForegroundService : Service() { .setContentIntent(pendingIntent) .setStyle(style) - if (currentAlbumArt != null) { - builder.setLargeIcon(currentAlbumArt) + if (currentSong != null) { + val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) + builder.setLargeIcon(bitmap) } // Add Play/Pause action 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 99228cf7c..1b3632826 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 @@ -55,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() @@ -126,6 +131,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 From 520517d1e2c1f83fc63a85639a13d86ad269a0a3 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 14:45:48 -0500 Subject: [PATCH 05/12] Sync UI and service state, improve notifications. --- .../oboe/samples/powerplay/MainActivity.kt | 36 ++++++++++---- .../engine/AudioForegroundService.kt | 47 +++++++++++++------ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index ad1053431..a4c5ac86d 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -80,6 +80,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Rect @@ -193,18 +194,21 @@ class MainActivity : ComponentActivity() { @Composable fun SongScreen() { val playList = PlayList - val pagerState = rememberPagerState(pageCount = { playList.count() }) + // Initialize state from player to ensure sync with Service + val initialPage = remember { player.currentSongIndex } + val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { playList.count() }) val playingSongIndex = remember { - mutableIntStateOf(0) + mutableIntStateOf(initialPage) } val offload = remember { - mutableIntStateOf(0) // 0: None, 1: Low Latency, 2: Power Saving, 3: PCM Offload + mutableIntStateOf(player.currentPerformanceMode.ordinal) } val isMMapEnabled = remember { mutableStateOf(player.isMMapEnabled()) } - val playerState by player.getPlayerStateLive().observeAsState(PlayerState.NoResultYet) - val isPlaying = playerState == PlayerState.Playing + // Use State object directly to avoid stale closure capture + val playerStateWrapper = player.getPlayerStateLive().observeAsState(PlayerState.NoResultYet) + val isPlaying = playerStateWrapper.value == PlayerState.Playing var sliderPosition by remember { mutableFloatStateOf(0f) } var currentPosition by remember { mutableFloatStateOf(0f) } @@ -225,7 +229,8 @@ class MainActivity : ComponentActivity() { .distinctUntilChanged() .collect { page -> playingSongIndex.intValue = pagerState.currentPage - if (isPlaying) { + // Check the latest value of playerState state object + if (playerStateWrapper.value == PlayerState.Playing) { player.startPlaying( playingSongIndex.intValue, OboePerformanceMode.fromInt(offload.intValue) @@ -235,6 +240,8 @@ class MainActivity : ComponentActivity() { } LaunchedEffect(Unit) { + // Check if assets are already loaded to avoid resetting if service is running + // Since we can't easily check, we reload. But we loop looping. playList.forEachIndexed { index, it -> player.loadFile(assets, it.fileName, index) player.setLooping(index, true) @@ -291,20 +298,29 @@ class MainActivity : ComponentActivity() { // Song Progress Slider Column(modifier = Modifier.padding(horizontal = 32.dp)) { + val isOffload = offload.intValue == 3 Slider( value = currentPosition, valueRange = 0f..duration, + enabled = !isOffload, onValueChange = { currentPosition = it player.seekTo(it.toInt()) - } + }, + modifier = Modifier.alpha(if (isOffload) 0f else 1f) ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = (currentPosition.toLong() * 1000 / sampleRate).convertToText()) - Text(text = (duration.toLong() * 1000 / sampleRate).convertToText()) + Text( + modifier = Modifier.alpha(if (isOffload) 0f else 1f), + text = (currentPosition.toLong() * 1000 / sampleRate).convertToText() + ) + Text( + modifier = Modifier.alpha(if (isOffload) 0f else 1f), + text = (duration.toLong() * 1000 / sampleRate).convertToText() + ) } } @@ -319,7 +335,7 @@ class MainActivity : ComponentActivity() { if (isOffloadSupported) radioOptions.add("PCM Offload") val (selectedOption, onOptionSelected) = remember { - mutableStateOf(radioOptions[0]) + mutableStateOf(radioOptions[offload.intValue]) } val enabled = !isPlaying radioOptions.forEachIndexed { index, text -> 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 7cd48dcad..35e0e2986 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 @@ -24,6 +24,7 @@ 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 @@ -32,7 +33,6 @@ import android.media.MediaMetadata import android.media.session.MediaSession import android.media.session.PlaybackState import android.os.Binder -import android.os.Build import android.os.IBinder import android.os.PowerManager import android.util.Log @@ -46,6 +46,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AudioForegroundService : Service() { @@ -53,6 +54,7 @@ class AudioForegroundService : Service() { 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() @@ -82,10 +84,7 @@ class AudioForegroundService : Service() { private val playerStateObserver = Observer { state -> updatePlaybackState() - // Update notification to reflect Play/Pause icon if needed - val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, notification) + updateNotification() if (state == PlayerState.Playing) { startProgressUpdater() @@ -95,10 +94,7 @@ class AudioForegroundService : Service() { } private val songIndexObserver = Observer { index -> - updatePlaybackState() - val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, notification) + loadAlbumArt(index) } override fun onCreate() { @@ -202,6 +198,18 @@ class AudioForegroundService : Service() { } } + 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 startProgressUpdater() { playbackJob?.cancel() playbackJob = serviceScope.launch { @@ -242,7 +250,12 @@ class AudioForegroundService : Service() { ) mediaSession.setPlaybackState(playbackStateBuilder.build()) + } + private fun updateMetadata() { + if (!::player.isInitialized || !::mediaSession.isInitialized) return + + val sampleRate = 48000L val durationFrames = player.getDuration().toLong() val durationMs = (durationFrames * 1000) / sampleRate @@ -255,14 +268,19 @@ class AudioForegroundService : Service() { .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) - if (currentSong != null) { - val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) - metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) + if (currentAlbumArt != null) { + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt) } mediaSession.setMetadata(metadataBuilder.build()) } + private fun updateNotification() { + val notification = createNotification() + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + private fun createNotification(): Notification { val channelId = createNotificationChannel() val notificationIntent = Intent(this, MainActivity::class.java) @@ -292,9 +310,8 @@ class AudioForegroundService : Service() { .setContentIntent(pendingIntent) .setStyle(style) - if (currentSong != null) { - val bitmap = BitmapFactory.decodeResource(resources, currentSong.cover) - builder.setLargeIcon(bitmap) + if (currentAlbumArt != null) { + builder.setLargeIcon(currentAlbumArt) } // Add Play/Pause action From 863626898977dd3ab776a10f3ac453aa860832e5 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 14:57:51 -0500 Subject: [PATCH 06/12] PowerPlay: Move performance settings to bottom sheet --- .../oboe/samples/powerplay/MainActivity.kt | 394 ++++++++++++------ 1 file changed, 259 insertions(+), 135 deletions(-) diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index a4c5ac86d..65a78ff28 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -28,6 +28,7 @@ import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -54,7 +55,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState @@ -62,12 +67,25 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -189,7 +207,7 @@ class MainActivity : ComponentActivity() { /*** * Brings together all UI elements for the player */ - @OptIn(ExperimentalAnimationApi::class) + @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) @Preview @Composable fun SongScreen() { @@ -213,6 +231,10 @@ class MainActivity : ComponentActivity() { var sliderPosition by remember { mutableFloatStateOf(0f) } var currentPosition by remember { mutableFloatStateOf(0f) } var duration by remember { mutableFloatStateOf(1f) } + + // Bottom sheet state + var showBottomSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // Poll for playback progress LaunchedEffect(isPlaying) { @@ -248,14 +270,29 @@ class MainActivity : ComponentActivity() { } } - - Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), contentAlignment = Alignment.Center ) { val configuration = LocalConfiguration.current + // Settings icon at bottom of screen + IconButton( + onClick = { showBottomSheet = true }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Performance Settings", + tint = Color.Black, + modifier = Modifier.size(32.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { AnimatedContent(targetState = playingSongIndex.intValue, transitionSpec = { (scaleIn() + fadeIn()) with (scaleOut() + fadeOut()) @@ -324,137 +361,6 @@ class MainActivity : ComponentActivity() { } } - Spacer(modifier = Modifier.height(24.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[offload.intValue]) - } - val enabled = !isPlaying - radioOptions.forEachIndexed { index, text -> - Row( - Modifier - .height(32.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 = 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) { - 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, - 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, @@ -481,6 +387,224 @@ class MainActivity : ComponentActivity() { } } } + + // Performance Modes Bottom Sheet + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + 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 = { showBottomSheet = false } + ) + } + } + } + + /** + * 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 + ) { + // Header + Text( + text = "Performance Modes", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Radio Group for Performance Modes + 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)) + + // Status Label + 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)) + + // MMAP Control + 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) + ) + } + + // Buffer Size Slider (only in PCM Offload mode) - animated entrance + 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)) + + // Circular X dismiss button + 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 From 4855858016eae3e63ab0a620a6b48bdd8ddcde7e Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 15:13:10 -0500 Subject: [PATCH 07/12] Add info dialog for audio settings --- .../oboe/samples/powerplay/MainActivity.kt | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index 65a78ff28..ca9f3ccd5 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -79,10 +79,13 @@ import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border @@ -235,6 +238,9 @@ class MainActivity : ComponentActivity() { // Bottom sheet state var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + // Info dialog state + var showInfoDialog by remember { mutableStateOf(false) } // Poll for playback progress LaunchedEffect(isPlaying) { @@ -278,12 +284,12 @@ class MainActivity : ComponentActivity() { ) { val configuration = LocalConfiguration.current - // Settings icon at bottom of screen + // Settings icon at bottom-right IconButton( onClick = { showBottomSheet = true }, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 32.dp) + .align(Alignment.BottomEnd) + .padding(32.dp) ) { Icon( imageVector = Icons.Default.Settings, @@ -292,6 +298,21 @@ class MainActivity : ComponentActivity() { modifier = Modifier.size(32.dp) ) } + + // Info icon at bottom-left + 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 = { @@ -406,6 +427,59 @@ class MainActivity : ComponentActivity() { ) } } + + // Info Dialog showing current audio settings + 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 = { showInfoDialog = false }, + 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 = { showInfoDialog = false }) { + Text("Close") + } + }, + containerColor = Color.White, + titleContentColor = Color.Black, + textContentColor = Color.Black + ) + } } /** From 45be1938356f2aa70cad17c778b4d893ec6a87f7 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 16:29:14 -0500 Subject: [PATCH 08/12] Self code Review --- .../src/main/cpp/PowerPlayMultiPlayer.cpp | 17 +++++++++++++---- .../powerplay/engine/AudioForegroundService.kt | 15 +++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index 825eccdc6..c67a67bde 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -172,12 +172,21 @@ void PowerPlayMultiPlayer::triggerDown(int32_t index, oboe::PerformanceMode perf } void PowerPlayMultiPlayer::updatePerformanceMode(oboe::PerformanceMode performanceMode) { + // If the performance mode has changed, we need to reopen the stream. + // Also reopen if the user has changed the MMAP policy (enabled/disabled) since the stream was opened. if (performanceMode != mLastPerformanceMode || - isMMapEnabled() != mLastMMapEnabled) { - - __android_log_print(ANDROID_LOG_INFO, TAG, "updatePerformanceMode: Reopening stream"); + isMMapEnabled() != mLastMMapEnabled) { teardownAudioStream(); - openStream(performanceMode); + + // Attempt here to reopen the stream with the new performance mode. + const auto result = openStream(performanceMode); + if (!result) { + // Something went wrong and the stream could not be reopened. + __android_log_print(ANDROID_LOG_ERROR, + TAG, + "Failed to reopen stream with new performance mode"); + return; + } } } 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 35e0e2986..03543450e 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 @@ -163,7 +163,7 @@ class AudioForegroundService : Service() { } if (!wakeLock.isHeld) { - wakeLock.acquire() + wakeLock.acquire(10*60*1000L /*10 minutes*/) } } catch (e: Throwable) { Log.e(TAG, "Error in onStartCommand", e) @@ -182,10 +182,6 @@ class AudioForegroundService : Service() { player.teardownAudioStream() } stopProgressUpdater() - // Cancel service scope - // serviceScope.cancel() // Actually Job() inside, maybe just cancel the job. - // Or if I used MainScope(), cancel it. - // But since it's a Service, canceling scope is good. if (::audioManager.isInitialized) { audioManager.abandonAudioFocusRequest(audioFocusRequest) @@ -268,6 +264,7 @@ class AudioForegroundService : Service() { .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) + if (currentAlbumArt != null) { metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt) } @@ -277,7 +274,7 @@ class AudioForegroundService : Service() { private fun updateNotification() { val notification = createNotification() - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, notification) } @@ -291,7 +288,6 @@ class AudioForegroundService : Service() { PendingIntent.FLAG_IMMUTABLE ) - // Update notification icon based on state val isPlaying = if (::player.isInitialized) { player.getPlayerStateLive().value == PlayerState.Playing } else false @@ -314,17 +310,12 @@ class AudioForegroundService : Service() { builder.setLargeIcon(currentAlbumArt) } - // Add Play/Pause action if (isPlaying) { val pauseIntent = PendingIntent.getService( this, 1, Intent(this, AudioForegroundService::class.java).apply { action = "PAUSE" }, PendingIntent.FLAG_IMMUTABLE ) - // We'd need to handle this intent in onStartCommand if we added buttons here. - // For now, relying on MediaSession transport controls is implicit for some Android versions/devices - // but strictly speaking for Notification.MediaStyle, we usually add actions. - // Since I didn't implement onStartCommand action handling, I'll rely on MediaSession callback. } return builder.build() From bb234db7e381740c30d366beb783114ef523f72f Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 16:36:52 -0500 Subject: [PATCH 09/12] PowerPlay: Update getDuration and wake lock --- .../powerplay/src/main/cpp/PowerPlayJNI.cpp | 5 ++-- .../src/main/cpp/PowerPlayMultiPlayer.cpp | 26 ++++++------------- .../src/main/cpp/PowerPlayMultiPlayer.h | 2 +- .../engine/AudioForegroundService.kt | 1 - 4 files changed, 12 insertions(+), 22 deletions(-) diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp index ad46e6514..db2c6cc06 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp @@ -304,8 +304,9 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentPos JNIEXPORT jint JNICALL Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getDurationNative( JNIEnv *, - jobject) { - return player.getDuration(); + jobject, + jint index) { + return player.getDuration(index); } /** diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index c67a67bde..8aff7368f 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -172,21 +172,12 @@ void PowerPlayMultiPlayer::triggerDown(int32_t index, oboe::PerformanceMode perf } void PowerPlayMultiPlayer::updatePerformanceMode(oboe::PerformanceMode performanceMode) { - // If the performance mode has changed, we need to reopen the stream. - // Also reopen if the user has changed the MMAP policy (enabled/disabled) since the stream was opened. if (performanceMode != mLastPerformanceMode || - isMMapEnabled() != mLastMMapEnabled) { - teardownAudioStream(); + isMMapEnabled() != mLastMMapEnabled) { - // Attempt here to reopen the stream with the new performance mode. - const auto result = openStream(performanceMode); - if (!result) { - // Something went wrong and the stream could not be reopened. - __android_log_print(ANDROID_LOG_ERROR, - TAG, - "Failed to reopen stream with new performance mode"); - return; - } + __android_log_print(ANDROID_LOG_INFO, TAG, "updatePerformanceMode: Reopening stream"); + teardownAudioStream(); + openStream(performanceMode); } } @@ -283,12 +274,11 @@ int32_t PowerPlayMultiPlayer::getCurrentPosition() { return 0; } -int32_t PowerPlayMultiPlayer::getDuration() { - int32_t index = getCurrentlyPlayingIndex(); - if (index != -1) { - return mSampleSources[index]->getNumFrames(); +int32_t PowerPlayMultiPlayer::getDuration(int32_t index) { + if (index < 0 || index >= mSampleSources.size()) { + return 0; } - return 0; + return mSampleSources[index]->getNumFrames(); } void PowerPlayMultiPlayer::seekTo(int32_t positionFrames) { diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index ed4d3c69d..aa3e3c275 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -54,7 +54,7 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { int32_t getCurrentPosition(); - int32_t getDuration(); + int32_t getDuration(int32_t index); void seekTo(int32_t positionFrames); 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 03543450e..0cded2c32 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 @@ -264,7 +264,6 @@ class AudioForegroundService : Service() { .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) - if (currentAlbumArt != null) { metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, currentAlbumArt) } From e8dacb03865b746c56c8ff16c1e8284659839e7c Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 16:42:50 -0500 Subject: [PATCH 10/12] PowerPlay: Remove playback progress tracking --- .../powerplay/src/main/cpp/PowerPlayJNI.cpp | 34 +--------- .../src/main/cpp/PowerPlayMultiPlayer.cpp | 57 +---------------- .../src/main/cpp/PowerPlayMultiPlayer.h | 4 -- .../src/main/cpp/PowerPlaySampleSource.h | 42 ------------- .../oboe/samples/powerplay/MainActivity.kt | 41 ------------ .../engine/AudioForegroundService.kt | 62 ------------------- .../powerplay/engine/PowerPlayAudioPlayer.kt | 7 --- 7 files changed, 2 insertions(+), 245 deletions(-) delete mode 100644 samples/powerplay/src/main/cpp/PowerPlaySampleSource.h diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp index db2c6cc06..d853cc206 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp @@ -28,7 +28,6 @@ #include #include "PowerPlayMultiPlayer.h" -#include "PowerPlaySampleSource.h" static const char *TAG = "PowerPlayJNI"; @@ -154,7 +153,7 @@ JNIEXPORT void JNICALL Java_com_google_oboe_samples_powerplay_engine_PowerPlayAu auto *sampleBuffer = new SampleBuffer(); sampleBuffer->loadSampleData(&reader); - const auto source = new PowerPlaySampleSource(sampleBuffer, 0); + const auto source = new OneShotSampleSource(sampleBuffer, 0); player.addSampleSource(source, sampleBuffer); delete[] buf; @@ -288,37 +287,6 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getBufferCapa return player.getBufferCapacityInFrames(); } -/** - * Native (JNI) implementation of PowerPlayAudioPlayer.getCurrentPositionNative() - */ -JNIEXPORT jint JNICALL -Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentPositionNative( - JNIEnv *, - jobject) { - return player.getCurrentPosition(); -} - -/** - * Native (JNI) implementation of PowerPlayAudioPlayer.getDurationNative() - */ -JNIEXPORT jint JNICALL -Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getDurationNative( - JNIEnv *, - jobject, - jint index) { - return player.getDuration(index); -} - -/** - * Native (JNI) implementation of PowerPlayAudioPlayer.seekToNative() - */ -JNIEXPORT void JNICALL -Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_seekToNative( - JNIEnv *, - jobject, - jint positionFrames) { - player.seekTo(positionFrames); -} #ifdef __cplusplus } diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index 8aff7368f..4550e15f4 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -14,9 +14,8 @@ * limitations under the License. */ #include -#include +#include #include "PowerPlayMultiPlayer.h" -#include "PowerPlaySampleSource.h" static const char *TAG = "PowerPlayMultiPlayer"; @@ -236,57 +235,3 @@ int32_t PowerPlayMultiPlayer::getBufferCapacityInFrames() { return mAudioStream->getBufferCapacityInFrames(); } - -int32_t PowerPlayMultiPlayer::getCurrentPosition() { - int32_t index = getCurrentlyPlayingIndex(); - if (index != -1) { - auto source = dynamic_cast(mSampleSources[index]); - if (source) { - int64_t sourceFrameIndex = source->getPositionInFrames(); - - if (mAudioStream != nullptr) { - int64_t framesWritten = mAudioStream->getFramesWritten(); - int64_t framesPresented = 0; - int64_t presentationTimeNs = 0; - - auto result = mAudioStream->getTimestamp(CLOCK_MONOTONIC, &framesPresented, &presentationTimeNs); - - if (result == Result::OK) { - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - int64_t nowNs = (now.tv_sec * 1000000000LL) + now.tv_nsec; - - int64_t deltaNs = nowNs - presentationTimeNs; - if (deltaNs < 0) deltaNs = 0; - - int64_t deltaFrames = (deltaNs * mAudioStream->getSampleRate()) / 1000000000LL; - int64_t currentFramesPresented = framesPresented + deltaFrames; - - int64_t latencyFrames = framesWritten - currentFramesPresented; - int64_t playhead = sourceFrameIndex - latencyFrames; - if (playhead < 0) playhead = 0; - return (int32_t)playhead; - } - } - return (int32_t)sourceFrameIndex; - } - } - return 0; -} - -int32_t PowerPlayMultiPlayer::getDuration(int32_t index) { - if (index < 0 || index >= mSampleSources.size()) { - return 0; - } - return mSampleSources[index]->getNumFrames(); -} - -void PowerPlayMultiPlayer::seekTo(int32_t positionFrames) { - int32_t index = getCurrentlyPlayingIndex(); - if (index != -1) { - auto source = dynamic_cast(mSampleSources[index]); - if (source) { - source->setPositionInFrames(positionFrames); - } - } -} diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index aa3e3c275..b2af820eb 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -54,10 +54,6 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { int32_t getCurrentPosition(); - int32_t getDuration(int32_t index); - - void seekTo(int32_t positionFrames); - private: class MyPresentationCallback : public oboe::AudioStreamPresentationCallback { public: diff --git a/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h b/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h deleted file mode 100644 index 40796e100..000000000 --- a/samples/powerplay/src/main/cpp/PowerPlaySampleSource.h +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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. - */ - -#ifndef SAMPLES_POWERPLAYSAMPLESOURCE_H -#define SAMPLES_POWERPLAYSAMPLESOURCE_H - -#include - -class PowerPlaySampleSource : public iolib::OneShotSampleSource { -public: - using OneShotSampleSource::OneShotSampleSource; - - int32_t getPositionInFrames() const { - if (mSampleBuffer == nullptr) return 0; - int32_t channels = mSampleBuffer->getProperties().channelCount; - if (channels <= 0) return 0; - return mCurSampleIndex / channels; - } - - void setPositionInFrames(int32_t frameIndex) { - if (mSampleBuffer == nullptr) return; - int32_t channels = mSampleBuffer->getProperties().channelCount; - mCurSampleIndex = frameIndex * channels; - if (mCurSampleIndex < 0) mCurSampleIndex = 0; - if (mCurSampleIndex > mSampleBuffer->getNumSamples()) mCurSampleIndex = mSampleBuffer->getNumSamples(); - } -}; - -#endif //SAMPLES_POWERPLAYSAMPLESOURCE_H diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index ca9f3ccd5..accfb7726 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -242,16 +242,6 @@ class MainActivity : ComponentActivity() { // Info dialog state var showInfoDialog by remember { mutableStateOf(false) } - // Poll for playback progress - LaunchedEffect(isPlaying) { - while (isPlaying) { - currentPosition = player.getCurrentPosition().toFloat() - val dur = player.getDuration() - if (dur > 0) duration = dur.toFloat() - delay(100) - } - } - LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage } .distinctUntilChanged() @@ -352,36 +342,6 @@ class MainActivity : ComponentActivity() { VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter) } } - Spacer(modifier = Modifier.height(24.dp)) - - // Song Progress Slider - Column(modifier = Modifier.padding(horizontal = 32.dp)) { - val isOffload = offload.intValue == 3 - Slider( - value = currentPosition, - valueRange = 0f..duration, - enabled = !isOffload, - onValueChange = { - currentPosition = it - player.seekTo(it.toInt()) - }, - modifier = Modifier.alpha(if (isOffload) 0f else 1f) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - modifier = Modifier.alpha(if (isOffload) 0f else 1f), - text = (currentPosition.toLong() * 1000 / sampleRate).convertToText() - ) - Text( - modifier = Modifier.alpha(if (isOffload) 0f else 1f), - text = (duration.toLong() * 1000 / sampleRate).convertToText() - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) Row( horizontalArrangement = Arrangement.SpaceEvenly, @@ -611,7 +571,6 @@ class MainActivity : ComponentActivity() { ) } - // Buffer Size Slider (only in PCM Offload mode) - animated entrance AnimatedVisibility( visible = offload.intValue == 3, enter = androidx.compose.animation.expandVertically() + fadeIn(), 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 0cded2c32..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 @@ -82,17 +82,6 @@ class AudioForegroundService : Service() { } } - private val playerStateObserver = Observer { state -> - updatePlaybackState() - updateNotification() - - if (state == PlayerState.Playing) { - startProgressUpdater() - } else { - stopProgressUpdater() - } - } - private val songIndexObserver = Observer { index -> loadAlbumArt(index) } @@ -134,8 +123,6 @@ class AudioForegroundService : Service() { isActive = true } - // Observe player state - player.getPlayerStateLive().observeForever(playerStateObserver) player.getCurrentSongIndexLive().observeForever(songIndexObserver) val powerManager = getSystemService(POWER_SERVICE) as PowerManager @@ -176,12 +163,10 @@ class AudioForegroundService : Service() { override fun onDestroy() { super.onDestroy() if (::player.isInitialized) { - player.getPlayerStateLive().removeObserver(playerStateObserver) player.getCurrentSongIndexLive().removeObserver(songIndexObserver) player.stopPlaying(player.currentSongIndex) player.teardownAudioStream() } - stopProgressUpdater() if (::audioManager.isInitialized) { audioManager.abandonAudioFocusRequest(audioFocusRequest) @@ -206,61 +191,14 @@ class AudioForegroundService : Service() { } } - private fun startProgressUpdater() { - playbackJob?.cancel() - playbackJob = serviceScope.launch { - while (isActive) { - updatePlaybackState() - delay(1000) // Update every second - } - } - } - - private fun stopProgressUpdater() { - playbackJob?.cancel() - playbackJob = null - updatePlaybackState() // Update one last time for final state - } - - private fun updatePlaybackState() { - if (!::player.isInitialized || !::mediaSession.isInitialized) return - - val state = player.getPlayerStateLive().value - val position = player.getCurrentPosition().toLong() // frames - val sampleRate = 48000L - val positionMs = (position * 1000) / sampleRate - - val playbackStateBuilder = PlaybackState.Builder() - - if (state == PlayerState.Playing) { - playbackStateBuilder.setState(PlaybackState.STATE_PLAYING, positionMs, 1.0f) - } else { - playbackStateBuilder.setState(PlaybackState.STATE_PAUSED, positionMs, 0.0f) - } - - playbackStateBuilder.setActions( - PlaybackState.ACTION_PLAY or - PlaybackState.ACTION_PAUSE or - PlaybackState.ACTION_STOP or - PlaybackState.ACTION_SEEK_TO - ) - - mediaSession.setPlaybackState(playbackStateBuilder.build()) - } - private fun updateMetadata() { if (!::player.isInitialized || !::mediaSession.isInitialized) return - - val sampleRate = 48000L - val durationFrames = player.getDuration().toLong() - val durationMs = (durationFrames * 1000) / sampleRate val currentSong = PlayList.getOrNull(player.currentSongIndex) val songTitle = currentSong?.name ?: "PowerPlay Audio" val songArtist = currentSong?.artist ?: "Playing..." val metadataBuilder = MediaMetadata.Builder() - .putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) .putString(MediaMetadata.METADATA_KEY_TITLE, songTitle) .putString(MediaMetadata.METADATA_KEY_ARTIST, songArtist) 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 1b3632826..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 @@ -109,10 +109,6 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { fun getBufferCapacityInFrames(): Int = getBufferCapacityInFramesNative() - fun getCurrentPosition(): Int = getCurrentPositionNative() - fun getDuration(): Int = getDurationNative() - fun seekTo(positionFrames: Int) = seekToNative(positionFrames) - /** * Native functions. * Load the library containing the native code including the JNI functions. @@ -137,9 +133,6 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { private external fun isMMapSupportedNative(): Boolean private external fun setBufferSizeInFramesNative(bufferSizeInFrames: Int): Int private external fun getBufferCapacityInFramesNative(): Int - private external fun getCurrentPositionNative(): Int - private external fun getDurationNative(): Int - private external fun seekToNative(positionFrames: Int) /** * Companion From 002f8090f3254459ebaf5dc3bc0ef394b6e3afd9 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 16:56:09 -0500 Subject: [PATCH 11/12] Self Review: Address Nits --- samples/iolib/src/main/cpp/player/SampleSource.h | 8 -------- samples/powerplay/src/main/cpp/PowerPlayJNI.cpp | 1 - samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp | 1 - samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h | 4 ---- 4 files changed, 14 deletions(-) diff --git a/samples/iolib/src/main/cpp/player/SampleSource.h b/samples/iolib/src/main/cpp/player/SampleSource.h index f03d075bb..f73034263 100644 --- a/samples/iolib/src/main/cpp/player/SampleSource.h +++ b/samples/iolib/src/main/cpp/player/SampleSource.h @@ -18,7 +18,6 @@ #define _PLAYER_SAMPLESOURCE_ #include -#include #include "DataSource.h" @@ -64,13 +63,6 @@ class SampleSource: public DataSource { int32_t getPlayHeadPosition() const { return mCurSampleIndex; } - int32_t getNumFrames() const { - if (mSampleBuffer == nullptr) return 0; - int32_t channels = mSampleBuffer->getProperties().channelCount; - if (channels <= 0) return 0; - return mSampleBuffer->getNumSamples() / channels; - } - void setPan(float pan) { if (pan < PAN_HARDLEFT) { mPan = PAN_HARDLEFT; diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp index d853cc206..f491dd81f 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp @@ -287,7 +287,6 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getBufferCapa return player.getBufferCapacityInFrames(); } - #ifdef __cplusplus } #endif diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index 4550e15f4..4f5889ff8 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -14,7 +14,6 @@ * limitations under the License. */ #include -#include #include "PowerPlayMultiPlayer.h" static const char *TAG = "PowerPlayMultiPlayer"; diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index b2af820eb..e8a6037ac 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -44,16 +44,12 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { static bool isMMapSupported(); - bool isMMapUsed(); - int32_t getCurrentlyPlayingIndex(); int32_t setBufferSizeInFrames(int32_t bufferSizeInFrames); int32_t getBufferCapacityInFrames(); - int32_t getCurrentPosition(); - private: class MyPresentationCallback : public oboe::AudioStreamPresentationCallback { public: From 9e9d3cbed90197139ecbe2b4016201b0df7b96da Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 28 Jan 2026 17:00:09 -0500 Subject: [PATCH 12/12] Self Review: Remove unnecessary comments --- .../oboe/samples/powerplay/MainActivity.kt | 44 +++---------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index accfb7726..5d3950696 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -40,6 +40,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.animation.with import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -144,7 +145,6 @@ class MainActivity : ComponentActivity() { override fun onServiceConnected(className: ComponentName, service: IBinder) { val binder = service as AudioForegroundService.LocalBinder player = binder.getService().player - // Re-check support flags as player is now available isMMapSupported = player.isMMapSupported() isBound.value = true } @@ -172,7 +172,6 @@ class MainActivity : ComponentActivity() { serviceIntent = Intent(this, AudioForegroundService::class.java) isOffloadSupported = AudioManager.isOffloadedPlaybackSupported(format, attributes) - // isMMapSupported checked in connection callback setContent { MusicPlayerTheme { @@ -198,7 +197,6 @@ class MainActivity : ComponentActivity() { unbindService(connection) isBound.value = false } - // Player lifecycle is now managed by Service } private fun setUpPowerPlayAudioPlayer() { @@ -215,7 +213,6 @@ class MainActivity : ComponentActivity() { @Composable fun SongScreen() { val playList = PlayList - // Initialize state from player to ensure sync with Service val initialPage = remember { player.currentSongIndex } val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { playList.count() }) val playingSongIndex = remember { @@ -226,20 +223,13 @@ class MainActivity : ComponentActivity() { } val isMMapEnabled = remember { mutableStateOf(player.isMMapEnabled()) } - - // Use State object directly to avoid stale closure capture val playerStateWrapper = player.getPlayerStateLive().observeAsState(PlayerState.NoResultYet) val isPlaying = playerStateWrapper.value == PlayerState.Playing - var sliderPosition by remember { mutableFloatStateOf(0f) } - var currentPosition by remember { mutableFloatStateOf(0f) } - var duration by remember { mutableFloatStateOf(1f) } - - // Bottom sheet state + var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - // Info dialog state var showInfoDialog by remember { mutableStateOf(false) } LaunchedEffect(pagerState) { @@ -258,8 +248,6 @@ class MainActivity : ComponentActivity() { } LaunchedEffect(Unit) { - // Check if assets are already loaded to avoid resetting if service is running - // Since we can't easily check, we reload. But we loop looping. playList.forEachIndexed { index, it -> player.loadFile(assets, it.fileName, index) player.setLooping(index, true) @@ -273,8 +261,6 @@ class MainActivity : ComponentActivity() { contentAlignment = Alignment.Center ) { val configuration = LocalConfiguration.current - - // Settings icon at bottom-right IconButton( onClick = { showBottomSheet = true }, modifier = Modifier @@ -289,7 +275,6 @@ class MainActivity : ComponentActivity() { ) } - // Info icon at bottom-left IconButton( onClick = { showInfoDialog = true }, modifier = Modifier @@ -316,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, @@ -361,18 +346,15 @@ class MainActivity : ComponentActivity() { ) } } - - // isPlaying is now observed from flow }) Spacer(modifier = Modifier.width(20.dp)) } } } - // Performance Modes Bottom Sheet if (showBottomSheet) { ModalBottomSheet( - onDismissRequest = { showBottomSheet = false }, + onDismissRequest = { }, sheetState = sheetState, containerColor = Color.White, shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) @@ -383,12 +365,11 @@ class MainActivity : ComponentActivity() { isPlaying = isPlaying, sliderPosition = sliderPosition, onSliderPositionChange = { sliderPosition = it }, - onDismiss = { showBottomSheet = false } + onDismiss = { } ) } } - // Info Dialog showing current audio settings if (showInfoDialog) { val performanceModeText = when (offload.intValue) { 0 -> "None" @@ -405,7 +386,7 @@ class MainActivity : ComponentActivity() { } AlertDialog( - onDismissRequest = { showInfoDialog = false }, + onDismissRequest = { }, title = { Text( text = "Audio Settings Info", @@ -431,7 +412,7 @@ class MainActivity : ComponentActivity() { } }, confirmButton = { - TextButton(onClick = { showInfoDialog = false }) { + TextButton(onClick = { }) { Text("Close") } }, @@ -465,7 +446,6 @@ class MainActivity : ComponentActivity() { .padding(bottom = 32.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Header Text( text = "Performance Modes", fontSize = 20.sp, @@ -473,8 +453,6 @@ class MainActivity : ComponentActivity() { color = Color.Black, modifier = Modifier.padding(bottom = 16.dp) ) - - // Radio Group for Performance Modes Column( modifier = Modifier.fillMaxWidth() ) { @@ -523,8 +501,6 @@ class MainActivity : ComponentActivity() { } Spacer(modifier = Modifier.height(12.dp)) - - // Status Label Text( text = when (offload.intValue) { 0 -> "Performance Mode: None" @@ -537,8 +513,6 @@ class MainActivity : ComponentActivity() { ) Spacer(modifier = Modifier.height(16.dp)) - - // MMAP Control Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() @@ -620,8 +594,6 @@ class MainActivity : ComponentActivity() { } Spacer(modifier = Modifier.height(24.dp)) - - // Circular X dismiss button Box( modifier = Modifier .size(48.dp) @@ -661,7 +633,6 @@ class MainActivity : ComponentActivity() { @Composable fun VinylAlbumCoverAnimation( - modifier: Modifier = Modifier, isSongPlaying: Boolean = true, painter: Painter ) { @@ -672,7 +643,6 @@ class MainActivity : ComponentActivity() { val rotation = remember { Animatable(currentRotation) } - LaunchedEffect(isSongPlaying) { if (isSongPlaying) { rotation.animateTo(