From 655f58aa2136d66a6a2b7d871fa35973ae14ee8d Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Tue, 24 Feb 2026 13:00:22 -0500 Subject: [PATCH 1/3] Add playback seeking and progress tracking. --- .../iolib/src/main/cpp/player/SampleSource.h | 8 ++ .../powerplay/src/main/cpp/PowerPlayJNI.cpp | 32 +++++++ .../src/main/cpp/PowerPlayMultiPlayer.cpp | 83 +++++++++++++++++++ .../src/main/cpp/PowerPlayMultiPlayer.h | 6 ++ .../oboe/samples/powerplay/MainActivity.kt | 67 +++++++++++++++ .../powerplay/engine/PowerPlayAudioPlayer.kt | 18 ++++ 6 files changed, 214 insertions(+) diff --git a/samples/iolib/src/main/cpp/player/SampleSource.h b/samples/iolib/src/main/cpp/player/SampleSource.h index f73034263..ffcb95a78 100644 --- a/samples/iolib/src/main/cpp/player/SampleSource.h +++ b/samples/iolib/src/main/cpp/player/SampleSource.h @@ -63,6 +63,14 @@ class SampleSource: public DataSource { int32_t getPlayHeadPosition() const { return mCurSampleIndex; } + void setPlayHeadPosition(int32_t position) { + if (mSampleBuffer != nullptr && position >= 0 && position < mSampleBuffer->getNumSamples()) { + mCurSampleIndex = position; + } + } + + SampleBuffer* getSampleBuffer() { return mSampleBuffer; } + 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 c3b19cb2a..5d6ed7e37 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp @@ -327,6 +327,38 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentlyP return player.getCurrentlyPlayingIndex(); } +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.getPlaybackPositionMillisNative() + */ +JNIEXPORT jlong JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getPlaybackPositionMillisNative( + JNIEnv *env, + jobject) { + return (jlong) player.getPlaybackPositionMillis(); +} + +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.seekToNative() + */ +JNIEXPORT void JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_seekToNative( + JNIEnv *env, + jobject, + jint positionMillis) { + player.seekTo(positionMillis); +} + +/** + * Native (JNI) implementation of PowerPlayAudioPlayer.getDurationMillisNative() + */ +JNIEXPORT jlong JNICALL +Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getDurationMillisNative( + JNIEnv *env, + jobject, + jint index) { + return (jlong) player.getDurationMillis(index); +} + #ifdef __cplusplus } #endif diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index ab52eb515..fb822b476 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -242,3 +242,86 @@ bool PowerPlayMultiPlayer::isOffloaded() { return mAudioStream->getPerformanceMode() == PerformanceMode::PowerSavingOffloaded; } + +int64_t PowerPlayMultiPlayer::getPlaybackPositionMillis() { + if (mAudioStream == nullptr) return 0; + + int32_t index = getCurrentlyPlayingIndex(); + if (index == -1) return 0; + + auto* sampleSource = mSampleSources[index]; + auto* sampleBuffer = sampleSource->getSampleBuffer(); + if (!sampleBuffer) return 0; + int32_t sampleChannels = sampleBuffer->getProperties().channelCount; + + int64_t framePosition = 0; + int64_t timeNanoseconds = 0; + auto result = mAudioStream->getTimestamp(CLOCK_MONOTONIC, &framePosition, &timeNanoseconds); + + int32_t sampleRate = mAudioStream->getSampleRate(); + if (sampleRate <= 0) return 0; + + int64_t readFrames = sampleSource->getPlayHeadPosition() / sampleChannels; + int64_t presentedFrame = 0; + + if (result == Result::OK) { + // Calculate the latency: how many frames are between the callback and the speakers. + int64_t framesWritten = mAudioStream->getFramesWritten(); + int64_t latencyFrames = framesWritten - framePosition; + if (latencyFrames < 0) latencyFrames = 0; + + presentedFrame = readFrames - latencyFrames; + } else { + // Fallback to callback position if timestamp is not available. + presentedFrame = readFrames; + } + + if (presentedFrame < 0) presentedFrame = 0; + + return (presentedFrame * 1000) / sampleRate; +} + +void PowerPlayMultiPlayer::seekTo(int32_t positionMillis) { + if (mAudioStream == nullptr) return; + + int32_t index = getCurrentlyPlayingIndex(); + if (index == -1) return; + + int32_t sampleRate = mAudioStream->getSampleRate(); + if (sampleRate <= 0) return; + + auto* sampleSource = mSampleSources[index]; + auto* sampleBuffer = sampleSource->getSampleBuffer(); + if (!sampleBuffer) return; + int32_t sampleChannels = sampleBuffer->getProperties().channelCount; + + int64_t targetFrame = (static_cast(positionMillis) * sampleRate) / 1000; + + // Boundary check for the current sample. + if (sampleBuffer) { + if (targetFrame < 0) targetFrame = 0; + int64_t totalFrames = sampleBuffer->getNumSamples() / sampleChannels; + if (targetFrame >= totalFrames) { + targetFrame = totalFrames - 1; + } + } + + sampleSource->setPlayHeadPosition(static_cast(targetFrame * sampleChannels)); + + // If offloaded, flush the stream so the seek is immediate. + if (isOffloaded()) { + mAudioStream->flush(); + } +} + +int64_t PowerPlayMultiPlayer::getDurationMillis(int32_t index) { + if (index < 0 || index >= mSampleSources.size()) return 0; + auto* sampleBuffer = mSampleSources[index]->getSampleBuffer(); + if (!sampleBuffer) return 0; + + int32_t channelCount = sampleBuffer->getProperties().channelCount; + int32_t sampleRate = mSampleRate; + if (sampleRate <= 0 || channelCount <= 0) return 0; + + return (static_cast(sampleBuffer->getNumSamples() / channelCount) * 1000) / sampleRate; +} diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h index 9ecda7090..0ad56c2b2 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h @@ -52,6 +52,12 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer { bool isOffloaded(); + int64_t getPlaybackPositionMillis(); + + void seekTo(int32_t positionMillis); + + int64_t getDurationMillis(int32_t index); + private: class MyPresentationCallback : public oboe::AudioStreamPresentationCallback { public: 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 c3a3d8232..0e7945b71 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 @@ -91,6 +91,7 @@ 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.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -129,6 +130,7 @@ import android.os.Looper import android.util.Log import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport.LOG_TAG +import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { @@ -402,11 +404,33 @@ class MainActivity : ComponentActivity() { var showInfoDialog by remember { mutableStateOf(false) } + // Real-time progress slider state + var assetsReady by remember { mutableStateOf(false) } + var playbackPosition by remember { mutableLongStateOf(0L) } + var isSeeking by remember { mutableStateOf(false) } + val duration = remember(playingSongIndex.intValue, assetsReady) { player.getDurationMillis(playingSongIndex.intValue) } + + // Polling loop for slider position (~60fps) + LaunchedEffect(isPlaying) { + if (isPlaying) { + while (true) { + if (!isSeeking) { + playbackPosition = player.getPlaybackPositionMillis() + } + delay(16) + } + } else { + playbackPosition = player.getPlaybackPositionMillis() + } + } + // Sync pager with song index when automation changes it LaunchedEffect(playingSongIndex.intValue) { if (pagerState.currentPage != playingSongIndex.intValue) { pagerState.animateScrollToPage(playingSongIndex.intValue) } + // Update playback position when song changes + playbackPosition = player.getPlaybackPositionMillis() } LaunchedEffect(pagerState) { @@ -434,6 +458,7 @@ class MainActivity : ComponentActivity() { } // Assets are now loaded, process any pending automation intent assetsLoaded = true + assetsReady = true pendingAutomationIntent?.let { processIntent(it) pendingAutomationIntent = null @@ -513,7 +538,49 @@ class MainActivity : ComponentActivity() { VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter) } } + Spacer(modifier = Modifier.height(24.dp)) + + // Progress Slider + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Slider( + value = if (duration > 0) playbackPosition.toFloat() / duration else 0f, + onValueChange = { newValue -> + isSeeking = true + playbackPosition = (newValue * duration).toLong() + }, + onValueChangeFinished = { + player.seekTo(playbackPosition.toInt()) + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary + ) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = playbackPosition.convertToText(), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = duration.convertToText(), + fontSize = 12.sp, + color = Color.Gray + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Row( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically 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 a5cffa7e7..5aff14519 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 @@ -130,6 +130,21 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { */ fun getCurrentlyPlayingIndex(): Int = getCurrentlyPlayingIndexNative() + /** + * Gets the current playback position in milliseconds. + */ + fun getPlaybackPositionMillis(): Long = getPlaybackPositionMillisNative() + + /** + * Seeks to a specific position in milliseconds. + */ + fun seekTo(positionMillis: Int) = seekToNative(positionMillis) + + /** + * Gets the duration of the track at the specified index in milliseconds. + */ + fun getDurationMillis(index: Int): Long = getDurationMillisNative(index) + /** * Native functions. * Load the library containing the native code including the JNI functions. @@ -157,6 +172,9 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { private external fun setVolumeNative(volume: Float) private external fun isOffloadedNative(): Boolean private external fun getCurrentlyPlayingIndexNative(): Int + private external fun getPlaybackPositionMillisNative(): Long + private external fun seekToNative(positionMillis: Int) + private external fun getDurationMillisNative(index: Int): Long /** * Companion From 15995d1d2d4def79e0df3f8bdab725115dfd50f4 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Tue, 24 Feb 2026 16:03:25 -0500 Subject: [PATCH 2/3] PowerPlay: Hide slider when in PCM Offload mode --- .../oboe/samples/powerplay/MainActivity.kt | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 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 0e7945b71..1fd32f13e 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 @@ -411,8 +411,8 @@ class MainActivity : ComponentActivity() { val duration = remember(playingSongIndex.intValue, assetsReady) { player.getDurationMillis(playingSongIndex.intValue) } // Polling loop for slider position (~60fps) - LaunchedEffect(isPlaying) { - if (isPlaying) { + LaunchedEffect(isPlaying, offload.intValue) { + if (isPlaying && offload.intValue != 3) { while (true) { if (!isSeeking) { playbackPosition = player.getPlaybackPositionMillis() @@ -542,40 +542,42 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(24.dp)) // Progress Slider - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - ) { - Slider( - value = if (duration > 0) playbackPosition.toFloat() / duration else 0f, - onValueChange = { newValue -> - isSeeking = true - playbackPosition = (newValue * duration).toLong() - }, - onValueChangeFinished = { - player.seekTo(playbackPosition.toInt()) - isSeeking = false - }, - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + AnimatedVisibility(visible = offload.intValue != 3) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) ) { - Text( - text = playbackPosition.convertToText(), - fontSize = 12.sp, - color = Color.Gray - ) - Text( - text = duration.convertToText(), - fontSize = 12.sp, - color = Color.Gray + Slider( + value = if (duration > 0) playbackPosition.toFloat() / duration else 0f, + onValueChange = { newValue -> + isSeeking = true + playbackPosition = (newValue * duration).toLong() + }, + onValueChangeFinished = { + player.seekTo(playbackPosition.toInt()) + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary + ) ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = playbackPosition.convertToText(), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = duration.convertToText(), + fontSize = 12.sp, + color = Color.Gray + ) + } } } From 4c2881affd8b57008855d1fa03daf03c164013ce Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 25 Feb 2026 13:06:02 -0500 Subject: [PATCH 3/3] Remove flush when offloaded. --- samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp index fb822b476..5764ba107 100644 --- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp +++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp @@ -307,11 +307,6 @@ void PowerPlayMultiPlayer::seekTo(int32_t positionMillis) { } sampleSource->setPlayHeadPosition(static_cast(targetFrame * sampleChannels)); - - // If offloaded, flush the stream so the seek is immediate. - if (isOffloaded()) { - mAudioStream->flush(); - } } int64_t PowerPlayMultiPlayer::getDurationMillis(int32_t index) {