diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 216e226036..aba925f1d3 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -41,6 +41,15 @@ * Rename `onTimelineRefreshed` to `onSourcePrepared` and `onPrepared` to `onTracksSelected` in `PreloadMediaSource.PreloadControl`. Also rename the IntDefs in `DefaultPreloadManager.Stage` accordingly. + * Add experimental support for dynamic scheduling to better align work + with CPU wake-cycles and delay waking up to when renderers can progress. + You can enable this using `experimentalSetDynamicSchedulingEnabled` when + setting up your ExoPlayer instance. + * Add `Renderer.getDurationToProgressMs`. A `Renderer` can implement this + method to return to ExoPlayer the duration that playback must advance in + order for the renderer to progress. If `ExoPlayer` is set with + `experimentalSetDynamicSchedulingEnabled` then `ExoPlayer` will call + this method when calculating the time to schedule its work task. * Transformer: * Work around a decoder bug where the number of audio channels was capped at stereo when handling PCM input. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java index 5098a451a8..d79a4fdff3 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -504,6 +504,7 @@ public interface ExoPlayer extends Player { /* package */ boolean buildCalled; /* package */ boolean suppressPlaybackOnUnsuitableOutput; /* package */ String playerName; + /* package */ boolean dynamicSchedulingEnabled; /** * Creates a builder. @@ -547,6 +548,7 @@ public interface ExoPlayer extends Player { *
If enabled, ExoPlayer's playback loop will run as rarely as possible by scheduling work + * for when {@link Renderer} progress can be made. + * + *
This method is experimental, and will be renamed or removed in a future release. + * + * @param dynamicSchedulingEnabled Whether to enable dynamic scheduling. + */ + @CanIgnoreReturnValue + @UnstableApi + public Builder experimentalSetDynamicSchedulingEnabled(boolean dynamicSchedulingEnabled) { + checkState(!buildCalled); + this.dynamicSchedulingEnabled = dynamicSchedulingEnabled; + return this; + } + /** * Sets whether the player should suppress playback that is attempted on an unsuitable output. * An example of an unsuitable audio output is the built-in speaker on a Wear OS device (unless diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 0851e32225..f5e9fc929f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -372,6 +372,7 @@ import java.util.concurrent.TimeoutException; builder.livePlaybackSpeedControl, builder.releaseTimeoutMs, pauseAtEndOfMediaItems, + builder.dynamicSchedulingEnabled, applicationLooper, clock, playbackInfoUpdateListener, diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index 872fbeec82..3878ba2036 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -174,8 +174,9 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS = 27; private static final int MSG_SET_PRELOAD_CONFIGURATION = 28; - private static final int ACTIVE_INTERVAL_MS = 10; - private static final int IDLE_INTERVAL_MS = 1000; + private static final long BUFFERING_MAXIMUM_INTERVAL_MS = + Util.usToMs(Renderer.DEFAULT_DURATION_TO_PROGRESS_US); + private static final long READY_MAXIMUM_INTERVAL_MS = 1000; /** * Duration for which the player needs to appear stuck before the playback is failed on the @@ -214,6 +215,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private final LivePlaybackSpeedControl livePlaybackSpeedControl; private final long releaseTimeoutMs; private final PlayerId playerId; + private final boolean dynamicSchedulingEnabled; @SuppressWarnings("unused") private SeekParameters seekParameters; @@ -234,6 +236,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; + private long rendererPositionElapsedRealtimeUs; private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; @Nullable private ExoPlaybackException pendingRecoverableRendererError; @@ -255,6 +258,7 @@ import java.util.concurrent.atomic.AtomicBoolean; LivePlaybackSpeedControl livePlaybackSpeedControl, long releaseTimeoutMs, boolean pauseAtEndOfWindow, + boolean dynamicSchedulingEnabled, Looper applicationLooper, Clock clock, PlaybackInfoUpdateListener playbackInfoUpdateListener, @@ -274,6 +278,7 @@ import java.util.concurrent.atomic.AtomicBoolean; this.releaseTimeoutMs = releaseTimeoutMs; this.setForegroundModeTimeoutMs = releaseTimeoutMs; this.pauseAtEndOfWindow = pauseAtEndOfWindow; + this.dynamicSchedulingEnabled = dynamicSchedulingEnabled; this.clock = clock; this.playerId = playerId; this.preloadConfiguration = preloadConfiguration; @@ -1111,7 +1116,7 @@ import java.util.concurrent.atomic.AtomicBoolean; @Nullable MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); if (playingPeriodHolder == null) { // We're still waiting until the playing period is available. - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + scheduleNextWork(operationStartTimeMs); return; } @@ -1122,7 +1127,7 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean renderersEnded = true; boolean renderersAllowPlayback = true; if (playingPeriodHolder.prepared) { - long rendererPositionElapsedRealtimeUs = msToUs(clock.elapsedRealtime()); + rendererPositionElapsedRealtimeUs = msToUs(clock.elapsedRealtime()); playingPeriodHolder.mediaPeriod.discardBuffer( playbackInfo.positionUs - backBufferDurationUs, retainBackBufferFromKeyframe); for (int i = 0; i < renderers.length; i++) { @@ -1230,12 +1235,11 @@ import java.util.concurrent.atomic.AtomicBoolean; if (sleepingForOffload || playbackInfo.playbackState == Player.STATE_ENDED) { // No need to schedule next work. - } else if (isPlaying || playbackInfo.playbackState == Player.STATE_BUFFERING) { - // We are actively playing or waiting for data to be ready. Schedule next work quickly. - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); - } else if (playbackInfo.playbackState == Player.STATE_READY && enabledRendererCount != 0) { - // We are ready, but not playing. Schedule next work less often to handle non-urgent updates. - scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); + } else if ((isPlaying || playbackInfo.playbackState == Player.STATE_BUFFERING) + || (playbackInfo.playbackState == Player.STATE_READY && enabledRendererCount != 0)) { + // Schedule next work as either we are actively playing, buffering, or we + // are ready but not playing. + scheduleNextWork(operationStartTimeMs); } TraceUtil.endSection(); @@ -1266,8 +1270,26 @@ import java.util.concurrent.atomic.AtomicBoolean; return window.isLive() && window.isDynamic && window.windowStartTimeMs != C.TIME_UNSET; } - private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { - handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); + private void scheduleNextWork(long thisOperationStartTimeMs) { + long wakeUpTimeIntervalMs = + playbackInfo.playbackState == Player.STATE_READY + && (dynamicSchedulingEnabled || !shouldPlayWhenReady()) + ? READY_MAXIMUM_INTERVAL_MS + : BUFFERING_MAXIMUM_INTERVAL_MS; + if (dynamicSchedulingEnabled && shouldPlayWhenReady()) { + for (Renderer renderer : renderers) { + if (isRendererEnabled(renderer)) { + wakeUpTimeIntervalMs = + min( + wakeUpTimeIntervalMs, + Util.usToMs( + renderer.getDurationToProgressUs( + rendererPositionUs, rendererPositionElapsedRealtimeUs))); + } + } + } + handler.sendEmptyMessageAtTime( + MSG_DO_SOME_WORK, thisOperationStartTimeMs + wakeUpTimeIntervalMs); } private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { @@ -2769,7 +2791,9 @@ import java.util.concurrent.atomic.AtomicBoolean; @Override public void onWakeup() { - handler.sendEmptyMessage(MSG_DO_SOME_WORK); + if (dynamicSchedulingEnabled || offloadSchedulingEnabled) { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } } }); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java index 215d043d04..18a0c1abe1 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java @@ -62,6 +62,14 @@ import java.util.List; @UnstableApi public interface Renderer extends PlayerMessage.Target { + /** + * Default minimum duration that the playback clock must advance before {@link #render} can make + * progress. + * + * @see #getDurationToProgressUs + */ + long DEFAULT_DURATION_TO_PROGRESS_US = 10_000L; + /** * Some renderers can signal when {@link #render(long, long)} should be called. * @@ -434,6 +442,23 @@ public interface Renderer extends PlayerMessage.Target { */ long getReadingPositionUs(); + /** + * Returns minimum amount of playback clock time that must pass in order for the {@link #render} + * call to make progress. + * + *
The default return time is {@link #DEFAULT_DURATION_TO_PROGRESS_US}.
+ *
+ * @param positionUs The current render position in microseconds, measured at the start of the
+ * current iteration of the rendering loop.
+ * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds,
+ * measured at the start of the current iteration of the rendering loop.
+ * @return Minimum amount of playback clock time that must pass before renderer is able to make
+ * progress.
+ */
+ default long getDurationToProgressUs(long positionUs, long elapsedRealtimeUs) {
+ return DEFAULT_DURATION_TO_PROGRESS_US;
+ }
+
/**
* Signals to the renderer that the current {@link SampleStream} will be the final one supplied
* before it is next disabled or reset.
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
index fc2db401d2..8ce6082f24 100644
--- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
@@ -10528,6 +10528,162 @@ public class ExoPlayerTest {
player.release();
}
+ @Test
+ public void play_withDynamicSchedulingEnabled_usesRendererDurationSchedulingInterval()
+ throws Exception {
+ AtomicInteger renderCounter = new AtomicInteger();
+ FakeDurationToProgressRenderer fakeRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_AUDIO, /* durationToProgressUs= */ 150_000L, renderCounter);
+ FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true);
+ ExoPlayer player =
+ new TestExoPlayerBuilder(context)
+ .setClock(clock)
+ .setDynamicSchedulingEnabled(true)
+ .setRenderers(fakeRenderer)
+ .build();
+ player.setMediaSource(
+ new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.AUDIO_FORMAT));
+ player.prepare();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_READY);
+
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 500);
+ renderCounter.set(0);
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 800);
+
+ assertThat(renderCounter.get()).isEqualTo(2);
+
+ player.release();
+ }
+
+ @Test
+ public void play_withDynamicSchedulingDisabled_usesDefaultSchedulingInterval() throws Exception {
+ AtomicInteger renderCounter = new AtomicInteger();
+ FakeDurationToProgressRenderer fakeRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_AUDIO, /* durationToProgressUs= */ 150_000L, renderCounter);
+ FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true);
+ ExoPlayer player =
+ new TestExoPlayerBuilder(context).setClock(clock).setRenderers(fakeRenderer).build();
+ player.setMediaSource(
+ new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.AUDIO_FORMAT));
+ player.prepare();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_READY);
+
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 500);
+ renderCounter.set(0);
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 800);
+
+ assertThat(renderCounter.get()).isEqualTo(30);
+
+ player.release();
+ }
+
+ @Test
+ public void
+ play_withDynamicSchedulingEnabledAndMultipleRenderers_usesMinimumDurationSchedulingInterval()
+ throws Exception {
+ AtomicInteger renderCounter = new AtomicInteger();
+ FakeDurationToProgressRenderer fakeAudioRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_AUDIO, /* durationToProgressUs= */ 150_000L, renderCounter);
+ FakeDurationToProgressRenderer fakeVideoRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_VIDEO, /* durationToProgressUs= */ 30_000L, /* renderCounter= */ null);
+ FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true);
+ ExoPlayer player =
+ new TestExoPlayerBuilder(context)
+ .setClock(clock)
+ .setDynamicSchedulingEnabled(true)
+ .setRenderers(fakeAudioRenderer, fakeVideoRenderer)
+ .build();
+ player.setMediaSource(
+ new FakeMediaSource(
+ new FakeTimeline(),
+ ExoPlayerTestRunner.AUDIO_FORMAT,
+ ExoPlayerTestRunner.VIDEO_FORMAT));
+ player.prepare();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_READY);
+
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 500);
+ renderCounter.set(0);
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 800);
+
+ assertThat(renderCounter.get()).isEqualTo(10);
+
+ player.release();
+ }
+
+ @Test
+ public void prepareOnly_withDynamicSchedulingEnabled_usesDefaultIdleSchedulingInterval()
+ throws Exception {
+ AtomicInteger renderCounter = new AtomicInteger();
+ FakeDurationToProgressRenderer fakeRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_AUDIO, /* durationToProgressUs= */ 150_000L, renderCounter);
+ FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true);
+ ExoPlayer player =
+ new TestExoPlayerBuilder(context)
+ .setClock(clock)
+ .setDynamicSchedulingEnabled(true)
+ .setRenderers(fakeRenderer)
+ .build();
+ player.setMediaSource(
+ new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.AUDIO_FORMAT));
+ player.prepare();
+
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 1000);
+ renderCounter.set(0);
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 3000);
+
+ assertThat(renderCounter.get()).isEqualTo(2);
+
+ player.release();
+ }
+
+ @Test
+ public void play_withDynamicSchedulingEnabledAndInBufferingState_usesBufferingSchedulingInterval()
+ throws Exception {
+ AtomicInteger renderCounter = new AtomicInteger();
+ AtomicBoolean allowStreamRead = new AtomicBoolean();
+ FakeDurationToProgressRenderer fakeRenderer =
+ new FakeDurationToProgressRenderer(
+ C.TRACK_TYPE_AUDIO, /* durationToProgressUs= */ 150_000L, renderCounter) {
+ @Override
+ public boolean isReady() {
+ // Always return false so player will stay in a buffering state.
+ return false;
+ }
+ ;
+ };
+ FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true);
+ ExoPlayer player =
+ new TestExoPlayerBuilder(context)
+ .setClock(clock)
+ .setDynamicSchedulingEnabled(true)
+ .setRenderers(fakeRenderer)
+ .build();
+ // Prevent reading any samples to keep player in a buffering state.
+ FakeDelayedMediaSource fakeDelayedMediaSource =
+ new FakeDelayedMediaSource(
+ new FakeTimeline(), ExoPlayerTestRunner.AUDIO_FORMAT, allowStreamRead);
+ player.setMediaSource(fakeDelayedMediaSource);
+ player.prepare();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_BUFFERING);
+
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 200);
+ renderCounter.set(0);
+ run(player).untilBackgroundThreadCondition(() -> clock.currentTimeMillis() >= 500);
+
+ assertThat(renderCounter.get()).isEqualTo(30);
+
+ player.release();
+ }
+
@Test
public void enablingOffload_withAudioOnly_playerSleeps() throws Exception {
FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO);
@@ -14435,71 +14591,7 @@ public class ExoPlayerTest {
Timeline timeline = new FakeTimeline();
AtomicBoolean allowStreamRead = new AtomicBoolean();
MediaSource delayedStreamSource =
- new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) {
- @Override
- protected MediaPeriod createMediaPeriod(
- MediaPeriodId id,
- TrackGroupArray trackGroupArray,
- Allocator allocator,
- MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher,
- DrmSessionManager drmSessionManager,
- DrmSessionEventListener.EventDispatcher drmEventDispatcher,
- @Nullable TransferListener transferListener) {
- long startPositionUs =
- -timeline
- .getPeriodByUid(id.periodUid, new Timeline.Period())
- .getPositionInWindowUs();
- // Add enough samples to the source so that the decoder can't decode everything at once.
- return new FakeMediaPeriod(
- trackGroupArray,
- allocator,
- (format, mediaPerioid) ->
- ImmutableList.of(
- oneByteSample(startPositionUs, C.BUFFER_FLAG_KEY_FRAME),
- oneByteSample(startPositionUs + 10_000),
- oneByteSample(startPositionUs + 20_000),
- oneByteSample(startPositionUs + 30_000),
- oneByteSample(startPositionUs + 40_000),
- oneByteSample(startPositionUs + 50_000),
- oneByteSample(startPositionUs + 60_000),
- oneByteSample(startPositionUs + 70_000),
- oneByteSample(startPositionUs + 80_000),
- oneByteSample(startPositionUs + 90_000),
- oneByteSample(startPositionUs + 100_000),
- END_OF_STREAM_ITEM),
- mediaSourceEventDispatcher,
- drmSessionManager,
- drmEventDispatcher,
- /* deferOnPrepared= */ false) {
- @Override
- protected FakeSampleStream createSampleStream(
- Allocator allocator,
- @Nullable MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher,
- DrmSessionManager drmSessionManager,
- DrmSessionEventListener.EventDispatcher drmEventDispatcher,
- Format initialFormat,
- List