Skip to content

Commit f0207e7

Browse files
authored
Add A Progress Slider to PowerPlay (#2345)
* Add playback seeking and progress tracking. * PowerPlay: Hide slider when in PCM Offload mode * Remove flush when offloaded.
1 parent bb3d818 commit f0207e7

6 files changed

Lines changed: 211 additions & 0 deletions

File tree

samples/iolib/src/main/cpp/player/SampleSource.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ class SampleSource: public DataSource {
6363

6464
int32_t getPlayHeadPosition() const { return mCurSampleIndex; }
6565

66+
void setPlayHeadPosition(int32_t position) {
67+
if (mSampleBuffer != nullptr && position >= 0 && position < mSampleBuffer->getNumSamples()) {
68+
mCurSampleIndex = position;
69+
}
70+
}
71+
72+
SampleBuffer* getSampleBuffer() { return mSampleBuffer; }
73+
6674
void setPan(float pan) {
6775
if (pan < PAN_HARDLEFT) {
6876
mPan = PAN_HARDLEFT;

samples/powerplay/src/main/cpp/PowerPlayJNI.cpp

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,38 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentlyP
327327
return player.getCurrentlyPlayingIndex();
328328
}
329329

330+
/**
331+
* Native (JNI) implementation of PowerPlayAudioPlayer.getPlaybackPositionMillisNative()
332+
*/
333+
JNIEXPORT jlong JNICALL
334+
Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getPlaybackPositionMillisNative(
335+
JNIEnv *env,
336+
jobject) {
337+
return (jlong) player.getPlaybackPositionMillis();
338+
}
339+
340+
/**
341+
* Native (JNI) implementation of PowerPlayAudioPlayer.seekToNative()
342+
*/
343+
JNIEXPORT void JNICALL
344+
Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_seekToNative(
345+
JNIEnv *env,
346+
jobject,
347+
jint positionMillis) {
348+
player.seekTo(positionMillis);
349+
}
350+
351+
/**
352+
* Native (JNI) implementation of PowerPlayAudioPlayer.getDurationMillisNative()
353+
*/
354+
JNIEXPORT jlong JNICALL
355+
Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getDurationMillisNative(
356+
JNIEnv *env,
357+
jobject,
358+
jint index) {
359+
return (jlong) player.getDurationMillis(index);
360+
}
361+
330362
#ifdef __cplusplus
331363
}
332364
#endif

samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,81 @@ bool PowerPlayMultiPlayer::isOffloaded() {
242242

243243
return mAudioStream->getPerformanceMode() == PerformanceMode::PowerSavingOffloaded;
244244
}
245+
246+
int64_t PowerPlayMultiPlayer::getPlaybackPositionMillis() {
247+
if (mAudioStream == nullptr) return 0;
248+
249+
int32_t index = getCurrentlyPlayingIndex();
250+
if (index == -1) return 0;
251+
252+
auto* sampleSource = mSampleSources[index];
253+
auto* sampleBuffer = sampleSource->getSampleBuffer();
254+
if (!sampleBuffer) return 0;
255+
int32_t sampleChannels = sampleBuffer->getProperties().channelCount;
256+
257+
int64_t framePosition = 0;
258+
int64_t timeNanoseconds = 0;
259+
auto result = mAudioStream->getTimestamp(CLOCK_MONOTONIC, &framePosition, &timeNanoseconds);
260+
261+
int32_t sampleRate = mAudioStream->getSampleRate();
262+
if (sampleRate <= 0) return 0;
263+
264+
int64_t readFrames = sampleSource->getPlayHeadPosition() / sampleChannels;
265+
int64_t presentedFrame = 0;
266+
267+
if (result == Result::OK) {
268+
// Calculate the latency: how many frames are between the callback and the speakers.
269+
int64_t framesWritten = mAudioStream->getFramesWritten();
270+
int64_t latencyFrames = framesWritten - framePosition;
271+
if (latencyFrames < 0) latencyFrames = 0;
272+
273+
presentedFrame = readFrames - latencyFrames;
274+
} else {
275+
// Fallback to callback position if timestamp is not available.
276+
presentedFrame = readFrames;
277+
}
278+
279+
if (presentedFrame < 0) presentedFrame = 0;
280+
281+
return (presentedFrame * 1000) / sampleRate;
282+
}
283+
284+
void PowerPlayMultiPlayer::seekTo(int32_t positionMillis) {
285+
if (mAudioStream == nullptr) return;
286+
287+
int32_t index = getCurrentlyPlayingIndex();
288+
if (index == -1) return;
289+
290+
int32_t sampleRate = mAudioStream->getSampleRate();
291+
if (sampleRate <= 0) return;
292+
293+
auto* sampleSource = mSampleSources[index];
294+
auto* sampleBuffer = sampleSource->getSampleBuffer();
295+
if (!sampleBuffer) return;
296+
int32_t sampleChannels = sampleBuffer->getProperties().channelCount;
297+
298+
int64_t targetFrame = (static_cast<int64_t>(positionMillis) * sampleRate) / 1000;
299+
300+
// Boundary check for the current sample.
301+
if (sampleBuffer) {
302+
if (targetFrame < 0) targetFrame = 0;
303+
int64_t totalFrames = sampleBuffer->getNumSamples() / sampleChannels;
304+
if (targetFrame >= totalFrames) {
305+
targetFrame = totalFrames - 1;
306+
}
307+
}
308+
309+
sampleSource->setPlayHeadPosition(static_cast<int32_t>(targetFrame * sampleChannels));
310+
}
311+
312+
int64_t PowerPlayMultiPlayer::getDurationMillis(int32_t index) {
313+
if (index < 0 || index >= mSampleSources.size()) return 0;
314+
auto* sampleBuffer = mSampleSources[index]->getSampleBuffer();
315+
if (!sampleBuffer) return 0;
316+
317+
int32_t channelCount = sampleBuffer->getProperties().channelCount;
318+
int32_t sampleRate = mSampleRate;
319+
if (sampleRate <= 0 || channelCount <= 0) return 0;
320+
321+
return (static_cast<int64_t>(sampleBuffer->getNumSamples() / channelCount) * 1000) / sampleRate;
322+
}

samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer {
5252

5353
bool isOffloaded();
5454

55+
int64_t getPlaybackPositionMillis();
56+
57+
void seekTo(int32_t positionMillis);
58+
59+
int64_t getDurationMillis(int32_t index);
60+
5561
private:
5662
class MyPresentationCallback : public oboe::AudioStreamPresentationCallback {
5763
public:

samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import androidx.compose.runtime.getValue
9191
import androidx.compose.runtime.livedata.observeAsState
9292
import androidx.compose.runtime.mutableFloatStateOf
9393
import androidx.compose.runtime.mutableIntStateOf
94+
import androidx.compose.runtime.mutableLongStateOf
9495
import androidx.compose.runtime.mutableStateOf
9596
import androidx.compose.runtime.remember
9697
import androidx.compose.runtime.setValue
@@ -129,6 +130,7 @@ import android.os.Looper
129130
import android.util.Log
130131
import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport
131132
import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport.LOG_TAG
133+
import kotlinx.coroutines.delay
132134

133135
class MainActivity : ComponentActivity() {
134136

@@ -402,11 +404,33 @@ class MainActivity : ComponentActivity() {
402404

403405
var showInfoDialog by remember { mutableStateOf(false) }
404406

407+
// Real-time progress slider state
408+
var assetsReady by remember { mutableStateOf(false) }
409+
var playbackPosition by remember { mutableLongStateOf(0L) }
410+
var isSeeking by remember { mutableStateOf(false) }
411+
val duration = remember(playingSongIndex.intValue, assetsReady) { player.getDurationMillis(playingSongIndex.intValue) }
412+
413+
// Polling loop for slider position (~60fps)
414+
LaunchedEffect(isPlaying, offload.intValue) {
415+
if (isPlaying && offload.intValue != 3) {
416+
while (true) {
417+
if (!isSeeking) {
418+
playbackPosition = player.getPlaybackPositionMillis()
419+
}
420+
delay(16)
421+
}
422+
} else {
423+
playbackPosition = player.getPlaybackPositionMillis()
424+
}
425+
}
426+
405427
// Sync pager with song index when automation changes it
406428
LaunchedEffect(playingSongIndex.intValue) {
407429
if (pagerState.currentPage != playingSongIndex.intValue) {
408430
pagerState.animateScrollToPage(playingSongIndex.intValue)
409431
}
432+
// Update playback position when song changes
433+
playbackPosition = player.getPlaybackPositionMillis()
410434
}
411435

412436
LaunchedEffect(pagerState) {
@@ -434,6 +458,7 @@ class MainActivity : ComponentActivity() {
434458
}
435459
// Assets are now loaded, process any pending automation intent
436460
assetsLoaded = true
461+
assetsReady = true
437462
pendingAutomationIntent?.let {
438463
processIntent(it)
439464
pendingAutomationIntent = null
@@ -513,7 +538,51 @@ class MainActivity : ComponentActivity() {
513538
VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter)
514539
}
515540
}
541+
516542
Spacer(modifier = Modifier.height(24.dp))
543+
544+
// Progress Slider
545+
AnimatedVisibility(visible = offload.intValue != 3) {
546+
Column(
547+
modifier = Modifier
548+
.fillMaxWidth()
549+
.padding(horizontal = 32.dp)
550+
) {
551+
Slider(
552+
value = if (duration > 0) playbackPosition.toFloat() / duration else 0f,
553+
onValueChange = { newValue ->
554+
isSeeking = true
555+
playbackPosition = (newValue * duration).toLong()
556+
},
557+
onValueChangeFinished = {
558+
player.seekTo(playbackPosition.toInt())
559+
isSeeking = false
560+
},
561+
colors = SliderDefaults.colors(
562+
thumbColor = MaterialTheme.colorScheme.primary,
563+
activeTrackColor = MaterialTheme.colorScheme.primary
564+
)
565+
)
566+
Row(
567+
modifier = Modifier.fillMaxWidth(),
568+
horizontalArrangement = Arrangement.SpaceBetween
569+
) {
570+
Text(
571+
text = playbackPosition.convertToText(),
572+
fontSize = 12.sp,
573+
color = Color.Gray
574+
)
575+
Text(
576+
text = duration.convertToText(),
577+
fontSize = 12.sp,
578+
color = Color.Gray
579+
)
580+
}
581+
}
582+
}
583+
584+
Spacer(modifier = Modifier.height(16.dp))
585+
517586
Row(
518587
horizontalArrangement = Arrangement.SpaceEvenly,
519588
verticalAlignment = Alignment.CenterVertically

samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
130130
*/
131131
fun getCurrentlyPlayingIndex(): Int = getCurrentlyPlayingIndexNative()
132132

133+
/**
134+
* Gets the current playback position in milliseconds.
135+
*/
136+
fun getPlaybackPositionMillis(): Long = getPlaybackPositionMillisNative()
137+
138+
/**
139+
* Seeks to a specific position in milliseconds.
140+
*/
141+
fun seekTo(positionMillis: Int) = seekToNative(positionMillis)
142+
143+
/**
144+
* Gets the duration of the track at the specified index in milliseconds.
145+
*/
146+
fun getDurationMillis(index: Int): Long = getDurationMillisNative(index)
147+
133148
/**
134149
* Native functions.
135150
* Load the library containing the native code including the JNI functions.
@@ -157,6 +172,9 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
157172
private external fun setVolumeNative(volume: Float)
158173
private external fun isOffloadedNative(): Boolean
159174
private external fun getCurrentlyPlayingIndexNative(): Int
175+
private external fun getPlaybackPositionMillisNative(): Long
176+
private external fun seekToNative(positionMillis: Int)
177+
private external fun getDurationMillisNative(index: Int): Long
160178

161179
/**
162180
* Companion

0 commit comments

Comments
 (0)