/*
* Copyright 2023 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 androidx.media3.exoplayer.video;
import static androidx.media3.common.util.Util.msToUs;
import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.content.Context;
import android.os.SystemClock;
import android.view.Surface;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.Renderer;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Controls the releasing of video frames. */
@UnstableApi
public final class VideoFrameReleaseControl {
/**
* The frame release action returned by {@link #getFrameReleaseAction(long, long, long, long,
* boolean, FrameReleaseInfo)}.
*
* <p>One of {@link #FRAME_RELEASE_IMMEDIATELY}, {@link #FRAME_RELEASE_SCHEDULED}, {@link
* #FRAME_RELEASE_DROP}, {@link #FRAME_RELEASE_IGNORE}, {@link ##FRAME_RELEASE_SKIP} or {@link
* #FRAME_RELEASE_TRY_AGAIN_LATER}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@UnstableApi
@IntDef({
FRAME_RELEASE_IMMEDIATELY,
FRAME_RELEASE_SCHEDULED,
FRAME_RELEASE_DROP,
FRAME_RELEASE_SKIP,
FRAME_RELEASE_IGNORE,
FRAME_RELEASE_TRY_AGAIN_LATER
})
public @interface FrameReleaseAction {}
/** Signals a frame should be released immediately. */
public static final int FRAME_RELEASE_IMMEDIATELY = 0;
/**
* Signals a frame should be scheduled for release. The release timestamp will be returned by
* {@link FrameReleaseInfo#getReleaseTimeNs()}.
*/
public static final int FRAME_RELEASE_SCHEDULED = 1;
/** Signals a frame should be dropped. */
public static final int FRAME_RELEASE_DROP = 2;
/** Signals that a frame should be skipped. */
public static final int FRAME_RELEASE_SKIP = 3;
/** Signals that a frame should be ignored. */
public static final int FRAME_RELEASE_IGNORE = 4;
/** Signals that a frame should not be released and the renderer should try again later. */
public static final int FRAME_RELEASE_TRY_AGAIN_LATER = 5;
/** Per {@link FrameReleaseAction} metadata. */
public static class FrameReleaseInfo {
private long earlyUs;
private long releaseTimeNs;
/** Resets this instances state. */
public FrameReleaseInfo() {
earlyUs = C.TIME_UNSET;
releaseTimeNs = C.TIME_UNSET;
}
/**
* Returns this frame's early time compared to the playback position, before any release time
* adjustment to the screen vsync slots.
*/
public long getEarlyUs() {
return earlyUs;
}
/**
* Returns the release time for the frame, in nanoseconds, or {@link C#TIME_UNSET} if the frame
* should not be released yet.
*/
public long getReleaseTimeNs() {
return releaseTimeNs;
}
private void reset() {
earlyUs = C.TIME_UNSET;
releaseTimeNs = C.TIME_UNSET;
}
}
/** Decides whether a frame should be forced to be released, or dropped. */
public interface FrameTimingEvaluator {
/**
* Whether a frame should be forced for release.
*
* @param earlyUs The time until the buffer should be presented in microseconds. A negative
* value indicates that the buffer is late.
* @param elapsedSinceLastReleaseUs The elapsed time since the last frame was released, in
* microseconds.
* @return Whether the video frame should be force released.
*/
boolean shouldForceReleaseFrame(long earlyUs, long elapsedSinceLastReleaseUs);
/**
* Returns whether the frame should be dropped.
*
* @param earlyUs The time until the buffer should be presented in microseconds. A negative
* value indicates that the buffer is late.
* @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds,
* measured at the start of the current iteration of the rendering loop.
* @param isLastFrame Whether the buffer is the last buffer in the current stream.
*/
boolean shouldDropFrame(long earlyUs, long elapsedRealtimeUs, boolean isLastFrame);
/**
* Returns whether this frame should be ignored.
*
* @param earlyUs The time until the buffer should be presented in microseconds. A negative
* value indicates that the buffer is late.
* @param positionUs The playback position, in microseconds.
* @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds,
* measured at the start of the current iteration of the rendering loop.
* @param isLastFrame Whether the buffer is the last buffer in the current stream.
* @param treatDroppedBuffersAsSkipped Whether dropped buffers should be treated as
* intentionally skipped.
* @return Whether this frame should be ignored.
* @throws ExoPlaybackException If an error occurs.
*/
boolean shouldIgnoreFrame(
long earlyUs,
long positionUs,
long elapsedRealtimeUs,
boolean isLastFrame,
boolean treatDroppedBuffersAsSkipped)
throws ExoPlaybackException;
}
/** The maximum earliest time, in microseconds, to release a frame on the surface. */
private static final long MAX_EARLY_US_THRESHOLD = 50_000;
private final FrameTimingEvaluator frameTimingEvaluator;
private final VideoFrameReleaseHelper frameReleaseHelper;
private final long allowedJoiningTimeMs;
private boolean started;
private @C.FirstFrameState int firstFrameState;
private long initialPositionUs;
private long lastReleaseRealtimeUs;
private long lastPresentationTimeUs;
private long joiningDeadlineMs;
private boolean joiningRenderNextFrameImmediately;
private float playbackSpeed;
private Clock clock;
/**
* Creates an instance.
*
* @param applicationContext The application context.
* @param frameTimingEvaluator The {@link FrameTimingEvaluator} that will assist in {@linkplain
* #getFrameReleaseAction(long, long, long, long, boolean, FrameReleaseInfo)} frame release
* actions}.
* @param allowedJoiningTimeMs The maximum duration in milliseconds for which the renderer can
* attempt to seamlessly join an ongoing playback.
*/
public VideoFrameReleaseControl(
Context applicationContext,
FrameTimingEvaluator frameTimingEvaluator,
long allowedJoiningTimeMs) {
this.frameTimingEvaluator = frameTimingEvaluator;
this.allowedJoiningTimeMs = allowedJoiningTimeMs;
frameReleaseHelper = new VideoFrameReleaseHelper(applicationContext);
firstFrameState = C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED;
initialPositionUs = C.TIME_UNSET;
lastPresentationTimeUs = C.TIME_UNSET;
joiningDeadlineMs = C.TIME_UNSET;
playbackSpeed = 1f;
clock = Clock.DEFAULT;
}
/** Called when the renderer is enabled. */
public void onEnabled(boolean releaseFirstFrameBeforeStarted) {
firstFrameState =
releaseFirstFrameBeforeStarted
? C.FIRST_FRAME_NOT_RENDERED
: C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED;
}
/** Called when the renderer is disabled. */
public void onDisabled() {
lowerFirstFrameState(C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED);
}
/** Called when the renderer is started. */
public void onStarted() {
started = true;
lastReleaseRealtimeUs = msToUs(clock.elapsedRealtime());
frameReleaseHelper.onStarted();
}
/** Called when the renderer is stopped. */
public void onStopped() {
started = false;
joiningDeadlineMs = C.TIME_UNSET;
frameReleaseHelper.onStopped();
}
/** Called when the renderer processed a stream change. */
public void onProcessedStreamChange() {
lowerFirstFrameState(C.FIRST_FRAME_NOT_RENDERED_AFTER_STREAM_CHANGE);
}
/** Called when the display surface changed. */
public void setOutputSurface(@Nullable Surface outputSurface) {
frameReleaseHelper.onSurfaceChanged(outputSurface);
lowerFirstFrameState(C.FIRST_FRAME_NOT_RENDERED);
}
/** Sets the frame rate. */
public void setFrameRate(float frameRate) {
frameReleaseHelper.onFormatChanged(frameRate);
}
/**
* Called when a frame have been released.
*
* @return Whether this is the first released frame.
*/
public boolean onFrameReleasedIsFirstFrame() {
boolean firstFrame = firstFrameState != C.FIRST_FRAME_RENDERED;
firstFrameState = C.FIRST_FRAME_RENDERED;
lastReleaseRealtimeUs = msToUs(clock.elapsedRealtime());
return firstFrame;
}
/** Sets the clock that will be used. */
public void setClock(Clock clock) {
this.clock = clock;
}
/**
* Allows the frame control to indicate the first frame can be released before this instance is
* started.
*/
public void allowReleaseFirstFrameBeforeStarted() {
if (firstFrameState == C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED) {
firstFrameState = C.FIRST_FRAME_NOT_RENDERED;
}
}
/**
* Whether the release control is ready to start playback.
*
* @see Renderer#isReady()
* @param rendererReady Whether the renderer is ready.
* @return Whether the release control is ready.
*/
public boolean isReady(boolean rendererReady) {
if (rendererReady && firstFrameState == C.FIRST_FRAME_RENDERED) {
// Ready. If we were joining then we've now joined, so clear the joining deadline.
joiningDeadlineMs = C.TIME_UNSET;
return true;
} else if (joiningDeadlineMs == C.TIME_UNSET) {
// Not joining.
return false;
} else if (clock.elapsedRealtime() < joiningDeadlineMs) {
// Joining and still withing the deadline.
return true;
} else {
// The joining deadline has been exceeded. Give up and clear the deadline.
joiningDeadlineMs = C.TIME_UNSET;
return false;
}
}
/**
* Joins the release control to a new stream.
*
* <p>The release control will pretend to be {@linkplain #isReady ready} for short time even if
* the first frame hasn't been rendered yet to avoid interrupting an ongoing playback.
*
* @param renderNextFrameImmediately Whether the next frame should be released as soon as possible
* or only at its preferred scheduled release time.
*/
public void join(boolean renderNextFrameImmediately) {
joiningRenderNextFrameImmediately = renderNextFrameImmediately;
joiningDeadlineMs =
allowedJoiningTimeMs > 0 ? (clock.elapsedRealtime() + allowedJoiningTimeMs) : C.TIME_UNSET;
}
/**
* Returns a {@link FrameReleaseAction} for a video frame which instructs a renderer what to do
* with the frame.
*
* @param presentationTimeUs The presentation time of the video frame, in microseconds.
* @param positionUs The current playback position, in microseconds.
* @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds,
* taken approximately at the time the playback position was {@code positionUs}.
* @param outputStreamStartPositionUs The stream's start position, in microseconds.
* @param isLastFrame Whether the frame is known to contain the last frame of the current stream.
* @param frameReleaseInfo A {@link FrameReleaseInfo} that will be filled with detailed data only
* if the method returns {@link #FRAME_RELEASE_IMMEDIATELY} or {@link
* #FRAME_RELEASE_SCHEDULED}.
* @return A {@link FrameReleaseAction} that should instruct the renderer whether to release the
* frame or not.
*/
public @FrameReleaseAction int getFrameReleaseAction(
long presentationTimeUs,
long positionUs,
long elapsedRealtimeUs,
long outputStreamStartPositionUs,
boolean isLastFrame,
FrameReleaseInfo frameReleaseInfo)
throws ExoPlaybackException {
frameReleaseInfo.reset();
if (initialPositionUs == C.TIME_UNSET) {
initialPositionUs = positionUs;
}
if (lastPresentationTimeUs != presentationTimeUs) {
frameReleaseHelper.onNextFrame(presentationTimeUs);
lastPresentationTimeUs = presentationTimeUs;
}
frameReleaseInfo.earlyUs =
calculateEarlyTimeUs(positionUs, elapsedRealtimeUs, presentationTimeUs);
if (shouldForceRelease(positionUs, frameReleaseInfo.earlyUs, outputStreamStartPositionUs)) {
return FRAME_RELEASE_IMMEDIATELY;
}
if (!started || positionUs == initialPositionUs) {
return FRAME_RELEASE_TRY_AGAIN_LATER;
}
// Calculate release time and and adjust earlyUs to screen vsync.
long systemTimeNs = clock.nanoTime();
frameReleaseInfo.releaseTimeNs =
frameReleaseHelper.adjustReleaseTime(systemTimeNs + (frameReleaseInfo.earlyUs * 1_000));
frameReleaseInfo.earlyUs = (frameReleaseInfo.releaseTimeNs - systemTimeNs) / 1_000;
// While joining, late frames are skipped while we catch up with the playback position.
boolean treatDropAsSkip =
joiningDeadlineMs != C.TIME_UNSET && !joiningRenderNextFrameImmediately;
if (frameTimingEvaluator.shouldIgnoreFrame(
frameReleaseInfo.earlyUs, positionUs, elapsedRealtimeUs, isLastFrame, treatDropAsSkip)) {
return FRAME_RELEASE_IGNORE;
} else if (frameTimingEvaluator.shouldDropFrame(
frameReleaseInfo.earlyUs, elapsedRealtimeUs, isLastFrame)) {
// While joining, dropped buffers are considered skipped.
return treatDropAsSkip ? FRAME_RELEASE_SKIP : FRAME_RELEASE_DROP;
} else if (frameReleaseInfo.earlyUs > MAX_EARLY_US_THRESHOLD) {
return FRAME_RELEASE_TRY_AGAIN_LATER;
}
return FRAME_RELEASE_SCHEDULED;
}
/** Resets the release control. */
public void reset() {
frameReleaseHelper.onPositionReset();
lastPresentationTimeUs = C.TIME_UNSET;
initialPositionUs = C.TIME_UNSET;
lowerFirstFrameState(C.FIRST_FRAME_NOT_RENDERED);
joiningDeadlineMs = C.TIME_UNSET;
}
/**
* Change the {@link C.VideoChangeFrameRateStrategy}, used when calling {@link
* Surface#setFrameRate}.
*/
public void setChangeFrameRateStrategy(
@C.VideoChangeFrameRateStrategy int changeFrameRateStrategy) {
frameReleaseHelper.setChangeFrameRateStrategy(changeFrameRateStrategy);
}
/** Sets the playback speed. Called when the renderer playback speed changes. */
public void setPlaybackSpeed(float speed) {
this.playbackSpeed = speed;
frameReleaseHelper.onPlaybackSpeed(speed);
}
private void lowerFirstFrameState(@C.FirstFrameState int firstFrameState) {
this.firstFrameState = min(this.firstFrameState, firstFrameState);
}
/**
* Calculates the time interval between the current player position and the frame presentation
* time.
*
* @param positionUs The current media time in microseconds, measured at the start of the current
* iteration of the rendering loop.
* @param elapsedRealtimeUs {@link SystemClock#elapsedRealtime()} in microseconds, measured at the
* start of the current iteration of the rendering loop.
* @param framePresentationTimeUs The presentation time of the frame in microseconds.
* @return The calculated early time, in microseconds.
*/
private long calculateEarlyTimeUs(
long positionUs, long elapsedRealtimeUs, long framePresentationTimeUs) {
// Calculate how early we are. In other words, the realtime duration that needs to elapse whilst
// the renderer is started before the frame should be rendered. A negative value means that
// we're already late.
// Note: Use of double rather than float is intentional for accuracy in the calculations below.
long earlyUs = (long) ((framePresentationTimeUs - positionUs) / (double) playbackSpeed);
if (started) {
// Account for the elapsed time since the start of this iteration of the rendering loop.
earlyUs -= Util.msToUs(clock.elapsedRealtime()) - elapsedRealtimeUs;
}
return earlyUs;
}
/** Returns whether a frame should be force released. */
private boolean shouldForceRelease(
long positionUs, long earlyUs, long outputStreamStartPositionUs) {
if (joiningDeadlineMs != C.TIME_UNSET && !joiningRenderNextFrameImmediately) {
// No force releasing of the initial or late frames during joining unless requested.
return false;
}
switch (firstFrameState) {
case C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED:
return started;
case C.FIRST_FRAME_NOT_RENDERED:
return true;
case C.FIRST_FRAME_NOT_RENDERED_AFTER_STREAM_CHANGE:
return positionUs >= outputStreamStartPositionUs;
case C.FIRST_FRAME_RENDERED:
long elapsedTimeSinceLastReleaseUs =
msToUs(clock.elapsedRealtime()) - lastReleaseRealtimeUs;
return started
&& frameTimingEvaluator.shouldForceReleaseFrame(earlyUs, elapsedTimeSinceLastReleaseUs);
default:
throw new IllegalStateException();
}
}
}