Compare commits

..

7 Commits

Author SHA1 Message Date
ibaker
170098b400 Fix SSA and SRT to display an in-progress cue when enabling subtitles
Issue: androidx/media#2309
PiperOrigin-RevId: 751424941
2025-04-25 08:27:44 -07:00
jbibik
35303c94a1 [ui-compose] Refactor PlayerSurface to use Kotlin function references
PiperOrigin-RevId: 751424812
2025-04-25 08:25:35 -07:00
claincly
09ce64ec21 Share some code for setting video output
The code for setting the video output is almost the same across both places,
with one callsite supporting less types of video output. I think it's still
better to share the code, to minimize the margin for mistake later.

PiperOrigin-RevId: 751423005
2025-04-25 08:20:33 -07:00
ibaker
8bf658cd79 Remove some hard-coding of file and content URI schemes
PiperOrigin-RevId: 751417429
2025-04-25 08:05:22 -07:00
jbibik
fe59718805 Refactor PlayerSurface Kotlin syntax to avoid variable shadowing
PiperOrigin-RevId: 751414538
2025-04-25 07:54:38 -07:00
ibaker
ae7d7dc7e8 Enable scrubbing for local files in the demo app
Scrubbing mode doesn't really work for assets loaded over the network.

This also assumes `asset://`, `data://` and `android.resource://` URIs
are 'local' but not `content://` - because these can be loaded by any
arbitrary `ContentResolver` which may do higher latency/lower bandwidth
remote loading.

PiperOrigin-RevId: 751389438
2025-04-25 06:19:16 -07:00
ibaker
49b57b8da3 Integrate PlayerControlView with new scrubbing mode
This also tweaks the logic in `Util.shouldShowPlayButton` to
special-case the 'scrubbing' suppression reason. In most cases of
playback suppression (e.g. transient loss of audio focus due to a phone
call), the recommended UX is to show a 'play' button (i.e. the UI looks
like the player is paused). This doesn't look right when scrubbing
since although 'ongoing' playback is suppressed, the image on screen
is constantly changing, so a 'pause' button is kept (i.e. the UI looks
like the player is playing).

PiperOrigin-RevId: 751385521
2025-04-25 06:05:00 -07:00
17 changed files with 205 additions and 57 deletions

View File

@ -40,6 +40,8 @@
* Improve codec performance checks for software video codecs. This may
lead to some additional tracks being marked as `EXCEEDS_CAPABILITIES`.
* Text:
* Fix SSA and SubRip to display an in-progress cue when enabling subtitles
([#2309](https://github.com/androidx/media/issues/2309)).
* Metadata:
* Image:
* DataSource:
@ -64,6 +66,13 @@
being enabled). Any changes made to the Player outside of the
observation period are now picked up
([#2313](https://github.com/androidx/media/issues/2313)).
* Add support for ExoPlayer's scrubbing mode to `PlayerControlView`. When
enabled, this puts the player into scrubbing mode when the user starts
dragging the scrubber bar, issues a `player.seekTo` call for every
movement, and then exits scrubbing mode when the touch is lifted from
the screen. This integration can be enabled with either
`time_bar_scrubbing_enabled = true` in XML or the
`setTimeBarScrubbingEnabled(boolean)` method from Java/Kotlin.
* Downloads:
* Add partial download support for progressive streams. Apps can prepare a
progressive stream with `DownloadHelper`, and request a

View File

@ -15,10 +15,12 @@
*/
package androidx.media3.demo.main;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Pair;
import android.view.KeyEvent;
import android.view.View;
@ -40,6 +42,7 @@ import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSchemeDataSource;
import androidx.media3.datasource.DataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.RenderersFactory;
@ -502,6 +505,25 @@ public class PlayerActivity extends AppCompatActivity
}
lastSeenTracks = tracks;
}
@OptIn(markerClass = UnstableApi.class) // For PlayerView.setTimeBarScrubbingEnabled
@Override
public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) {
if (playerView == null) {
return;
}
if (mediaItem == null) {
playerView.setTimeBarScrubbingEnabled(false);
return;
}
String uriScheme = mediaItem.localConfiguration.uri.getScheme();
playerView.setTimeBarScrubbingEnabled(
TextUtils.isEmpty(uriScheme)
|| uriScheme.equals(ContentResolver.SCHEME_FILE)
|| uriScheme.equals("asset")
|| uriScheme.equals(DataSchemeDataSource.SCHEME_DATA)
|| uriScheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE));
}
}
private class PlayerErrorMessageProvider implements ErrorMessageProvider<PlaybackException> {

View File

@ -47,6 +47,7 @@ import android.app.Service;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
@ -419,7 +420,8 @@ public final class Util {
}
private static boolean isMediaStoreExternalContentUri(Uri uri) {
if (!"content".equals(uri.getScheme()) || !MediaStore.AUTHORITY.equals(uri.getAuthority())) {
if (!Objects.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT)
|| !Objects.equals(uri.getAuthority(), MediaStore.AUTHORITY)) {
return false;
}
List<String> pathSegments = uri.getPathSegments();
@ -467,7 +469,7 @@ public final class Util {
@UnstableApi
public static boolean isLocalFileUri(Uri uri) {
String scheme = uri.getScheme();
return TextUtils.isEmpty(scheme) || "file".equals(scheme);
return TextUtils.isEmpty(scheme) || Objects.equals(scheme, ContentResolver.SCHEME_FILE);
}
/** Returns true if the code path is currently running on an emulator. */
@ -3713,7 +3715,9 @@ public final class Util {
|| player.getPlaybackState() == Player.STATE_IDLE
|| player.getPlaybackState() == Player.STATE_ENDED
|| (shouldShowPlayIfSuppressed
&& player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE);
&& player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
&& player.getPlaybackSuppressionReason()
!= Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING);
}
/**

View File

@ -32,6 +32,7 @@ import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.Objects;
/** A {@link DataSource} for reading from a content URI. */
@UnstableApi
@ -81,7 +82,7 @@ public final class ContentDataSource extends BaseDataSource {
transferInitializing(dataSpec);
AssetFileDescriptor assetFileDescriptor;
if ("content".equals(uri.getScheme())) {
if (Objects.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT)) {
Bundle providerOptions = new Bundle();
// We don't want compatible media transcoding.
providerOptions.putBoolean(MediaStore.EXTRA_ACCEPT_ORIGINAL_MEDIA_FORMAT, true);

View File

@ -122,7 +122,7 @@ public final class DefaultDataSource implements DataSource {
private static final String TAG = "DefaultDataSource";
private static final String SCHEME_ASSET = "asset";
private static final String SCHEME_CONTENT = "content";
private static final String SCHEME_CONTENT = ContentResolver.SCHEME_CONTENT;
private static final String SCHEME_RTMP = "rtmp";
private static final String SCHEME_UDP = "udp";
private static final String SCHEME_DATA = DataSchemeDataSource.SCHEME_DATA;

View File

@ -46,6 +46,7 @@ import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.MediaFormatUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSourceUtil;
import androidx.media3.datasource.DataSpec;
@ -287,11 +288,8 @@ public final class MediaExtractorCompat {
*/
public void setDataSource(Context context, Uri uri, @Nullable Map<String, String> headers)
throws IOException {
String scheme = uri.getScheme();
String path = uri.getPath();
if ((scheme == null || scheme.equals("file")) && path != null) {
// If the URI scheme is null or file, treat it as a local file path
setDataSource(path);
if (Util.isLocalFileUri(uri)) {
setDataSource(checkNotNull(uri.getPath()));
return;
}

View File

@ -259,7 +259,7 @@ public class SubtitleExtractor implements Extractor {
cuesWithTiming.startTimeUs,
cueEncoder.encode(cuesWithTiming.cues, cuesWithTiming.durationUs));
samples.add(sample);
if (seekTimeUs == C.TIME_UNSET || cuesWithTiming.startTimeUs >= seekTimeUs) {
if (seekTimeUs == C.TIME_UNSET || cuesWithTiming.endTimeUs >= seekTimeUs) {
writeToOutput(sample);
}
});

View File

@ -167,13 +167,14 @@ public final class SsaParser implements SubtitleParser {
}
long startTimeUs = startTimesUs.get(i);
// It's safe to inspect element i+1, because we already exited the loop above if i=size()-1.
long durationUs = startTimesUs.get(i + 1) - startTimesUs.get(i);
if (outputOptions.startTimeUs == C.TIME_UNSET || startTimeUs >= outputOptions.startTimeUs) {
output.accept(new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs));
long endTimeUs = startTimesUs.get(i + 1);
CuesWithTiming cuesWithTiming =
new CuesWithTiming(
cuesForThisStartTime, startTimeUs, /* durationUs= */ endTimeUs - startTimeUs);
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
output.accept(cuesWithTiming);
} else if (cuesWithTimingBeforeRequestedStartTimeUs != null) {
cuesWithTimingBeforeRequestedStartTimeUs.add(
new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs));
cuesWithTimingBeforeRequestedStartTimeUs.add(cuesWithTiming);
}
}
if (cuesWithTimingBeforeRequestedStartTimeUs != null) {

View File

@ -166,7 +166,7 @@ public final class SubripParser implements SubtitleParser {
break;
}
}
if (outputOptions.startTimeUs == C.TIME_UNSET || startTimeUs >= outputOptions.startTimeUs) {
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
output.accept(
new CuesWithTiming(
ImmutableList.of(buildCue(text, alignmentTag)),

View File

@ -129,7 +129,8 @@ public final class SsaParserTest {
SsaParser parser = new SsaParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL);
List<CuesWithTiming> cues = new ArrayList<>();
parser.parse(bytes, OutputOptions.onlyCuesAfter(/* startTimeUs= */ 1_000_000), cues::add);
// Choose a start time halfway through the second cue, and expect it to be included.
parser.parse(bytes, OutputOptions.onlyCuesAfter(/* startTimeUs= */ 3_000_000), cues::add);
assertThat(cues).hasSize(2);
assertTypicalCue2(cues.get(0));
@ -141,9 +142,10 @@ public final class SsaParserTest {
SsaParser parser = new SsaParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL);
List<CuesWithTiming> cues = new ArrayList<>();
// Choose a start time halfway through the second cue, and expect it to be considered 'after'.
parser.parse(
bytes,
OutputOptions.cuesAfterThenRemainingCuesBefore(/* startTimeUs= */ 1_000_000),
OutputOptions.cuesAfterThenRemainingCuesBefore(/* startTimeUs= */ 3_000_000),
cues::add);
assertThat(cues).hasSize(3);

View File

@ -105,7 +105,8 @@ public final class SubripParserTest {
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);
List<CuesWithTiming> cues = new ArrayList<>();
parser.parse(bytes, OutputOptions.onlyCuesAfter(/* startTimeUs= */ 1_000_000), cues::add);
// Choose a start time halfway through the second cue, and expect it to be included.
parser.parse(bytes, OutputOptions.onlyCuesAfter(/* startTimeUs= */ 3_000_000), cues::add);
assertThat(cues).hasSize(2);
assertTypicalCue2(cues.get(0));
@ -118,9 +119,10 @@ public final class SubripParserTest {
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);
List<CuesWithTiming> cues = new ArrayList<>();
// Choose a start time halfway through the second cue, and expect it to be considered 'after'.
parser.parse(
bytes,
OutputOptions.cuesAfterThenRemainingCuesBefore(/* startTimeUs= */ 1_000_000),
OutputOptions.cuesAfterThenRemainingCuesBefore(/* startTimeUs= */ 3_000_000),
cues::add);
assertThat(cues).hasSize(3);

View File

@ -400,6 +400,7 @@ public final class CompositionPlayer extends SimpleBasePlayer
* @param composition The {@link Composition} to play. Every {@link EditedMediaItem} in the {@link
* Composition} must have its {@link EditedMediaItem#durationUs} set.
*/
@SuppressWarnings("FutureReturnValueIgnored")
public void setComposition(Composition composition) {
verifyApplicationThread();
checkArgument(!composition.sequences.isEmpty());
@ -412,20 +413,9 @@ public final class CompositionPlayer extends SimpleBasePlayer
}
setCompositionInternal(composition);
if (videoOutput != null) {
if (videoOutput instanceof SurfaceHolder) {
setVideoSurfaceHolderInternal((SurfaceHolder) videoOutput);
} else if (videoOutput instanceof SurfaceView) {
SurfaceView surfaceView = (SurfaceView) videoOutput;
setVideoSurfaceHolderInternal(surfaceView.getHolder());
} else if (videoOutput instanceof Surface) {
setVideoSurfaceInternal((Surface) videoOutput, checkNotNull(videoOutputSize));
} else {
throw new IllegalStateException(videoOutput.getClass().toString());
}
}
// Update the composition field at the end after everything else has been set.
this.composition = composition;
maybeSetVideoOutput();
}
/**
@ -444,7 +434,6 @@ public final class CompositionPlayer extends SimpleBasePlayer
}
/** Sets the {@link Surface} and {@link Size} to render to. */
@VisibleForTesting
public void setVideoSurface(Surface surface, Size videoOutputSize) {
videoOutput = surface;
this.videoOutputSize = videoOutputSize;
@ -616,18 +605,12 @@ public final class CompositionPlayer extends SimpleBasePlayer
@Override
protected ListenableFuture<?> handleSetVideoOutput(Object videoOutput) {
if (!(videoOutput instanceof SurfaceHolder || videoOutput instanceof SurfaceView)) {
throw new UnsupportedOperationException(videoOutput.getClass().toString());
throw new UnsupportedOperationException(
videoOutput.getClass().toString()
+ ". Use CompositionPlayer.setVideoSurface() for Surface output.");
}
this.videoOutput = videoOutput;
if (composition == null) {
return Futures.immediateVoidFuture();
}
if (videoOutput instanceof SurfaceHolder) {
setVideoSurfaceHolderInternal((SurfaceHolder) videoOutput);
} else {
setVideoSurfaceHolderInternal(((SurfaceView) videoOutput).getHolder());
}
return Futures.immediateVoidFuture();
return maybeSetVideoOutput();
}
@Override
@ -1012,6 +995,25 @@ public final class CompositionPlayer extends SimpleBasePlayer
};
}
private ListenableFuture<?> maybeSetVideoOutput() {
if (videoOutput == null || composition == null) {
return Futures.immediateVoidFuture();
}
if (videoOutput instanceof SurfaceHolder) {
setVideoSurfaceHolderInternal((SurfaceHolder) videoOutput);
} else if (videoOutput instanceof SurfaceView) {
SurfaceView surfaceView = (SurfaceView) videoOutput;
setVideoSurfaceHolderInternal(surfaceView.getHolder());
} else if (videoOutput instanceof Surface) {
setVideoSurfaceInternal(
(Surface) videoOutput,
checkNotNull(videoOutputSize, "VideoOutputSize must be set when using Surface output"));
} else {
throw new IllegalStateException(videoOutput.getClass().toString());
}
return Futures.immediateVoidFuture();
}
private long getContentPositionMs() {
if (players.isEmpty()) {
return 0;

View File

@ -12,6 +12,8 @@
-keepnames class androidx.media3.exoplayer.ExoPlayer {}
-keepclassmembers class androidx.media3.exoplayer.ExoPlayer {
void setImageOutput(androidx.media3.exoplayer.image.ImageOutput);
void setScrubbingModeEnabled(boolean);
boolean isScrubbingModeEnabled();
}
-keepclasseswithmembers class androidx.media3.exoplayer.image.ImageOutput {
void onImageAvailable(long, android.graphics.Bitmap);

View File

@ -79,12 +79,15 @@ import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.common.collect.ImmutableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@ -92,6 +95,7 @@ import java.util.Formatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CopyOnWriteArrayList;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
/**
* A view for controlling {@link Player} instances.
@ -156,6 +160,14 @@ import java.util.concurrent.CopyOnWriteArrayList;
* <li>Corresponding method: {@link #setAnimationEnabled(boolean)}
* <li>Default: true
* </ul>
* <li><b>{@code time_bar_scrubbing_enabled}</b> - Whether the time bar should {@linkplain
* Player#seekTo seek} immediately as the user drags the scrubber around (true), or only seek
* when the user releases the scrubber (false). This can only be used if the {@linkplain
* #setPlayer connected player} is an instance of {@code androidx.media3.exoplayer.ExoPlayer}.
* <ul>
* <li>Corresponding method: {@link #setTimeBarScrubbingEnabled(boolean)}
* <li>Default: {@code false}
* </ul>
* <li><b>{@code time_bar_min_update_interval}</b> - Specifies the minimum interval between time
* bar position updates.
* <ul>
@ -352,6 +364,8 @@ public class PlayerControlView extends FrameLayout {
/** The maximum number of windows that can be shown in a multi-window time bar. */
public static final int MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR = 100;
private static final String TAG = "PlayerControlView";
/** The maximum interval between time bar position updates. */
private static final int MAX_UPDATE_INTERVAL_MS = 1_000;
@ -366,6 +380,9 @@ public class PlayerControlView extends FrameLayout {
private final PlayerControlViewLayoutManager controlViewLayoutManager;
private final Resources resources;
private final ComponentListener componentListener;
@Nullable private final Class<?> exoplayerClazz;
@Nullable private final Method setScrubbingModeEnabledMethod;
@Nullable private final Method isScrubbingModeEnabledMethod;
@SuppressWarnings("deprecation") // Using the deprecated type for now.
private final CopyOnWriteArrayList<VisibilityListener> visibilityListeners;
@ -442,6 +459,7 @@ public class PlayerControlView extends FrameLayout {
private boolean multiWindowTimeBar;
private boolean scrubbing;
private int showTimeoutMs;
private boolean timeBarScrubbingEnabled;
private int timeBarMinUpdateIntervalMs;
private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes;
private long[] adGroupTimesMs;
@ -572,6 +590,8 @@ public class PlayerControlView extends FrameLayout {
showSubtitleButton =
a.getBoolean(R.styleable.PlayerControlView_show_subtitle_button, showSubtitleButton);
showVrButton = a.getBoolean(R.styleable.PlayerControlView_show_vr_button, showVrButton);
timeBarScrubbingEnabled =
a.getBoolean(R.styleable.PlayerControlView_time_bar_scrubbing_enabled, false);
setTimeBarMinUpdateInterval(
a.getInt(
R.styleable.PlayerControlView_time_bar_min_update_interval,
@ -598,6 +618,21 @@ public class PlayerControlView extends FrameLayout {
extraPlayedAdGroups = new boolean[0];
updateProgressAction = this::updateProgress;
Class<?> exoplayerClazz = null;
Method setScrubbingModeEnabledMethod = null;
Method isScrubbingModeEnabledMethod = null;
try {
exoplayerClazz = Class.forName("androidx.media3.exoplayer.ExoPlayer");
setScrubbingModeEnabledMethod =
exoplayerClazz.getMethod("setScrubbingModeEnabled", boolean.class);
isScrubbingModeEnabledMethod = exoplayerClazz.getMethod("isScrubbingModeEnabled");
} catch (ClassNotFoundException | NoSuchMethodException e) {
// Expected if ExoPlayer module not available.
}
this.exoplayerClazz = exoplayerClazz;
this.setScrubbingModeEnabledMethod = setScrubbingModeEnabledMethod;
this.isScrubbingModeEnabledMethod = isScrubbingModeEnabledMethod;
durationView = findViewById(R.id.exo_duration);
positionView = findViewById(R.id.exo_position);
@ -1088,6 +1123,17 @@ public class PlayerControlView extends FrameLayout {
return controlViewLayoutManager.isAnimationEnabled();
}
/**
* Sets whether the time bar should {@linkplain Player#seekTo seek} immediately as the user drags
* the scrubber around (true), or only seek when the user releases the scrubber (false).
*
* <p>This can only be used if the {@linkplain #setPlayer connected player} is an instance of
* {@code androidx.media3.exoplayer.ExoPlayer}.
*/
public void setTimeBarScrubbingEnabled(boolean timeBarScrubbingEnabled) {
this.timeBarScrubbingEnabled = timeBarScrubbingEnabled;
}
/**
* Sets the minimum interval between time bar position updates.
*
@ -1852,6 +1898,21 @@ public class PlayerControlView extends FrameLayout {
positionView.setText(Util.getStringForTime(formatBuilder, formatter, position));
}
controlViewLayoutManager.removeHideCallbacks();
if (player != null && timeBarScrubbingEnabled) {
if (isExoPlayer(player)) {
try {
checkNotNull(setScrubbingModeEnabledMethod).invoke(player, true);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
} else {
Log.w(
TAG,
"Time bar scrubbing is enabled, but player is not an ExoPlayer instance, so ignoring"
+ " (because we can't enable scrubbing mode). player.class="
+ checkNotNull(player).getClass());
}
}
}
@Override
@ -1859,17 +1920,45 @@ public class PlayerControlView extends FrameLayout {
if (positionView != null) {
positionView.setText(Util.getStringForTime(formatBuilder, formatter, position));
}
boolean isScrubbingModeEnabled;
try {
isScrubbingModeEnabled =
isExoPlayer(player)
&& (boolean)
checkNotNull(checkNotNull(isScrubbingModeEnabledMethod).invoke(player));
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
if (isScrubbingModeEnabled) {
seekToTimeBarPosition(checkNotNull(player), position);
}
}
@Override
public void onScrubStop(TimeBar timeBar, long position, boolean canceled) {
scrubbing = false;
if (!canceled && player != null) {
seekToTimeBarPosition(player, position);
if (player != null) {
if (!canceled) {
seekToTimeBarPosition(player, position);
}
if (isExoPlayer(player)) {
try {
checkNotNull(setScrubbingModeEnabledMethod).invoke(player, false);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
controlViewLayoutManager.resetHideCallbacks();
}
@EnsuresNonNullIf(result = true, expression = "#1")
private boolean isExoPlayer(@Nullable Player player) {
return player != null
&& exoplayerClazz != null
&& exoplayerClazz.isAssignableFrom(player.getClass());
}
@Override
public void onDismiss() {
if (needToHideBars) {

View File

@ -1283,6 +1283,19 @@ public class PlayerView extends FrameLayout implements AdViewProvider {
controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar);
}
/**
* Sets whether the time bar should {@linkplain Player#seekTo seek} immediately as the user drags
* the scrubber around (true), or only seek when the user releases the scrubber (false).
*
* <p>This can only be used if the {@linkplain #setPlayer connected player} is an instance of
* {@code androidx.media3.exoplayer.ExoPlayer}.
*/
@UnstableApi
public void setTimeBarScrubbingEnabled(boolean timeBarScrubbingEnabled) {
Assertions.checkStateNotNull(controller);
controller.setTimeBarScrubbingEnabled(timeBarScrubbingEnabled);
}
/**
* Sets whether a play button is shown if playback is {@linkplain
* Player#getPlaybackSuppressionReason() suppressed}.

View File

@ -90,6 +90,7 @@
<attr name="subtitle_off_icon" format="reference"/>
<attr name="show_vr_button" format="boolean"/>
<attr name="vr_icon" format="reference"/>
<attr name="time_bar_scrubbing_enabled" format="boolean"/>
<attr name="time_bar_min_update_interval" format="integer"/>
<attr name="controller_layout_id" format="reference"/>
<attr name="animation_enabled" format="boolean"/>
@ -146,6 +147,7 @@
<attr name="subtitle_on_icon"/>
<attr name="show_vr_button"/>
<attr name="vr_icon"/>
<attr name="time_bar_scrubbing_enabled"/>
<attr name="time_bar_min_update_interval"/>
<attr name="controller_layout_id"/>
<attr name="animation_enabled"/>
@ -227,6 +229,7 @@
<attr name="subtitle_off_icon"/>
<attr name="show_vr_button"/>
<attr name="vr_icon"/>
<attr name="time_bar_scrubbing_enabled"/>
<attr name="time_bar_min_update_interval"/>
<attr name="controller_layout_id"/>
<attr name="animation_enabled"/>

View File

@ -57,17 +57,17 @@ fun PlayerSurface(
PlayerSurfaceInternal(
player,
modifier,
createView = { SurfaceView(it) },
setViewOnPlayer = { player, view -> player.setVideoSurfaceView(view) },
clearViewFromPlayer = { player, view -> player.clearVideoSurfaceView(view) },
createView = ::SurfaceView,
setVideoView = Player::setVideoSurfaceView,
clearVideoView = Player::clearVideoSurfaceView,
)
SURFACE_TYPE_TEXTURE_VIEW ->
PlayerSurfaceInternal(
player,
modifier,
createView = { TextureView(it) },
setViewOnPlayer = { player, view -> player.setVideoTextureView(view) },
clearViewFromPlayer = { player, view -> player.clearVideoTextureView(view) },
createView = ::TextureView,
setVideoView = Player::setVideoTextureView,
clearVideoView = Player::clearVideoTextureView,
)
else -> throw IllegalArgumentException("Unrecognized surface type: $surfaceType")
}
@ -78,8 +78,8 @@ private fun <T : View> PlayerSurfaceInternal(
player: Player,
modifier: Modifier,
createView: (Context) -> T,
setViewOnPlayer: (Player, T) -> Unit,
clearViewFromPlayer: (Player, T) -> Unit,
setVideoView: Player.(T) -> Unit,
clearVideoView: Player.(T) -> Unit,
) {
var view by remember { mutableStateOf<T?>(null) }
var registeredPlayer by remember { mutableStateOf<Player?>(null) }
@ -88,11 +88,11 @@ private fun <T : View> PlayerSurfaceInternal(
LaunchedEffect(view, player) {
registeredPlayer?.let { previousPlayer ->
if (previousPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE))
clearViewFromPlayer(previousPlayer, view)
previousPlayer.clearVideoView(view)
registeredPlayer = null
}
if (player.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) {
setViewOnPlayer(player, view)
player.setVideoView(view)
registeredPlayer = player
}
}