Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions samples/iolib/src/main/cpp/player/SampleSource.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,81 @@ 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<int64_t>(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<int32_t>(targetFrame * sampleChannels));
}

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<int64_t>(sampleBuffer->getNumSamples() / channelCount) * 1000) / sampleRate;
}
6 changes: 6 additions & 0 deletions samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {

Expand Down Expand Up @@ -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, offload.intValue) {
if (isPlaying && offload.intValue != 3) {
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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -513,7 +538,51 @@ class MainActivity : ComponentActivity() {
VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter)
}
}

Spacer(modifier = Modifier.height(24.dp))

// Progress Slider
AnimatedVisibility(visible = offload.intValue != 3) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down