Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.10.0

* Adds adaptive bitrate streaming support with `setBandwidthLimit` for HLS/DASH content.
* Configures ExoPlayer with `AdaptiveTrackSelection.Factory` and `DefaultBandwidthMeter` for optimized ABR.
* Uses `TextureView` in platform view mode for seamless resolution switching during quality transitions.

## 2.9.4

* Updates `androidx.media3` to 1.9.2.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import static androidx.media3.common.Player.REPEAT_MODE_ALL;
import static androidx.media3.common.Player.REPEAT_MODE_OFF;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media3.common.AudioAttributes;
Expand All @@ -19,7 +20,9 @@
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
import io.flutter.view.TextureRegistry.SurfaceProducer;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -93,6 +96,45 @@ private static void setAudioAttributes(ExoPlayer exoPlayer, boolean isMixMode) {
!isMixMode);
}

/**
* Creates an {@link ExoPlayer} configured for adaptive bitrate streaming.
*
* <p>Configures {@link AdaptiveTrackSelection.Factory} and {@link DefaultBandwidthMeter} so
* ExoPlayer selects video tracks based on measured network bandwidth.
*
* @param context application context.
* @param asset video asset providing the media source factory.
* @return a configured ExoPlayer instance.
*/
// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
@UnstableApi
@NonNull
public static ExoPlayer createAbrExoPlayer(
@NonNull Context context, @NonNull VideoAsset asset) {
DefaultBandwidthMeter bandwidthMeter =
new DefaultBandwidthMeter.Builder(context)
.setInitialBitrateEstimate(1_000_000)
.build();

DefaultTrackSelector trackSelector =
new DefaultTrackSelector(context, new AdaptiveTrackSelection.Factory());

trackSelector.setParameters(
trackSelector
.buildUponParameters()
.setAllowVideoNonSeamlessAdaptiveness(true)
.setAllowVideoMixedDecoderSupportAdaptiveness(true)
.setForceLowestBitrate(false)
.setForceHighestSupportedBitrate(false)
.build());

return new ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setBandwidthMeter(bandwidthMeter)
.setMediaSourceFactory(asset.getMediaSourceFactory(context))
.build();
}

@Override
public void play() {
exoPlayer.play();
Expand Down Expand Up @@ -233,6 +275,28 @@ public void selectAudioTrack(long groupIndex, long trackIndex) {
trackSelector.buildUponParameters().setOverrideForType(override).build());
}

// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
@UnstableApi
@Override
public void setBandwidthLimit(long maxBandwidthBps) {
if (trackSelector == null) {
return;
}

DefaultTrackSelector.Parameters.Builder parametersBuilder =
trackSelector.buildUponParameters();

if (maxBandwidthBps <= 0) {
parametersBuilder
.setMaxVideoBitrate(Integer.MAX_VALUE);
} else {
int maxBitrate = maxBandwidthBps > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) maxBandwidthBps;
parametersBuilder.setMaxVideoBitrate(maxBitrate);
}

trackSelector.setParameters(parametersBuilder.build());
}

public void dispose() {
if (disposeHandler != null) {
disposeHandler.onDispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,90 +5,90 @@
package io.flutter.plugins.videoplayer.platformview;

import android.content.Context;
import android.os.Build;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.graphics.SurfaceTexture;
import android.view.Surface;
import android.view.TextureView;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import io.flutter.plugin.platform.PlatformView;

/**
* A class used to create a native video view that can be embedded in a Flutter app. It wraps an
* {@link ExoPlayer} instance and displays its video content.
* A {@link PlatformView} that displays video content using a {@link TextureView}.
*
* <p>TextureView is used instead of SurfaceView to support seamless resolution changes during
* adaptive bitrate streaming (HLS/DASH). SurfaceView operates on a separate window layer which
* can cause visual artifacts when the video resolution changes mid-playback.
*/
@UnstableApi
public final class PlatformVideoView implements PlatformView {
@NonNull private final SurfaceView surfaceView;
@NonNull private final TextureView textureView;
@NonNull private final ExoPlayer exoPlayer;
private Surface surface;

/**
* Constructs a new PlatformVideoView.
*
* @param context The context in which the view is running.
* @param exoPlayer The ExoPlayer instance used to play the video.
*/
@OptIn(markerClass = UnstableApi.class)
public PlatformVideoView(@NonNull Context context, @NonNull ExoPlayer exoPlayer) {
surfaceView = new SurfaceView(context);
this.exoPlayer = exoPlayer;
this.textureView = new TextureView(context);

if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// Workaround for rendering issues on Android 9 (API 28).
// On Android 9, using setVideoSurfaceView seems to lead to issues where the first frame is
// not displayed if the video is paused initially.
// To ensure the first frame is visible, the surface is directly set using holder.getSurface()
// when the surface is created, and ExoPlayer seeks to a position to force rendering of the
// first frame.
setupSurfaceWithCallback(exoPlayer);
} else {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
// Avoid blank space instead of a video on Android versions below 8 by adjusting video's
// z-layer within the Android view hierarchy:
surfaceView.setZOrderMediaOverlay(true);
}
exoPlayer.setVideoSurfaceView(surfaceView);
}
}
textureView.setSurfaceTextureListener(
new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(
@NonNull SurfaceTexture surfaceTexture, int width, int height) {
surface = new Surface(surfaceTexture);
exoPlayer.setVideoSurface(surface);
}

private void setupSurfaceWithCallback(@NonNull ExoPlayer exoPlayer) {
surfaceView
.getHolder()
.addCallback(
new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
exoPlayer.setVideoSurface(holder.getSurface());
// Force first frame rendering:
exoPlayer.seekTo(1);
}
@Override
public void onSurfaceTextureSizeChanged(
@NonNull SurfaceTexture surfaceTexture, int width, int height) {
// No-op: ExoPlayer handles resolution changes during adaptive bitrate streaming.
// The MediaCodec decoder seamlessly adapts to the new resolution.
}

@Override
public void surfaceChanged(
@NonNull SurfaceHolder holder, int format, int width, int height) {
// No implementation needed.
}
@Override
public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) {
exoPlayer.setVideoSurface(null);
if (surface != null) {
surface.release();
surface = null;
}
return true;
}

@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
exoPlayer.setVideoSurface(null);
}
});
@Override
public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) {
// No-op.
}
});
}

/**
* Returns the view associated with this PlatformView.
*
* @return The SurfaceView used to display the video.
* @return The TextureView used to display the video.
*/
@NonNull
@Override
public View getView() {
return surfaceView;
return textureView;
}

/** Disposes of the resources used by this PlatformView. */
@Override
public void dispose() {
surfaceView.getHolder().getSurface().release();
textureView.setSurfaceTextureListener(null);
exoPlayer.setVideoSurface(null);
if (surface != null) {
surface.release();
surface = null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.media3.common.Format;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
import java.util.Objects;

public final class PlatformViewExoPlayerEventListener extends ExoPlayerEventListener {

public PlatformViewExoPlayerEventListener(
@NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events) {
super(exoPlayer, events);
Expand All @@ -25,8 +26,15 @@ protected void sendInitialized() {
// We can't rely on VideoSize here, because at this point it is not available - the platform
// view was not created yet. We use the video format instead.
Format videoFormat = exoPlayer.getVideoFormat();
if (videoFormat == null) {
// Format not yet available; report 0×0 so the player can still initialize.
// The native view will display at the correct size once layout occurs.
events.onInitialized(0, 0, exoPlayer.getDuration(), 0);
return;
}

RotationDegrees rotationCorrection =
RotationDegrees.fromDegrees(Objects.requireNonNull(videoFormat).rotationDegrees);
RotationDegrees.fromDegrees(videoFormat.rotationDegrees);
int width = videoFormat.width;
int height = videoFormat.height;

Expand All @@ -36,10 +44,16 @@ protected void sendInitialized() {
|| rotationCorrection == RotationDegrees.ROTATE_270) {
width = videoFormat.height;
height = videoFormat.width;

rotationCorrection = RotationDegrees.fromDegrees(0);
}

events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees());
}

@Override
public void onVideoSizeChanged(@NonNull VideoSize videoSize) {
// No-op: during adaptive bitrate quality transitions the resolution changes
// but we should not re-report initialization to Flutter. The native view
// and ExoPlayer handle the resize seamlessly.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,14 @@ public static PlatformViewVideoPlayer create(
@NonNull VideoPlayerCallbacks events,
@NonNull VideoAsset asset,
@NonNull VideoPlayerOptions options) {
return new PlatformViewVideoPlayer(

PlatformViewVideoPlayer player = new PlatformViewVideoPlayer(
events,
asset.getMediaItem(),
options,
() -> {
androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector =
new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context);
ExoPlayer.Builder builder =
new ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setMediaSourceFactory(asset.getMediaSourceFactory(context));
return builder.build();
});
() -> VideoPlayer.createAbrExoPlayer(context, asset));

return player;
}

@NonNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import androidx.annotation.OptIn;
import androidx.media3.common.Format;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
Expand Down Expand Up @@ -51,7 +52,14 @@ protected void sendInitialized() {
events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees());
}

@OptIn(markerClass = androidx.media3.common.util.UnstableApi.class)
@Override
public void onVideoSizeChanged(@NonNull VideoSize videoSize) {
// No-op: during adaptive bitrate quality transitions the resolution changes
// but we should not re-report initialization to Flutter. The texture surface
// automatically adapts to new dimensions.
}

@OptIn(markerClass = UnstableApi.class)
// A video's Format and its rotation degrees are unstable because they are not guaranteed
// the same implementation across API versions. It is possible that this logic may need
// revisiting should the implementation change across versions of the Exoplayer API.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
public final class TextureVideoPlayer extends VideoPlayer implements SurfaceProducer.Callback {
// True when the ExoPlayer instance has a null surface.
private boolean needsSurface = true;

/**
* Creates a texture video player.
*
Expand All @@ -49,20 +50,15 @@ public static TextureVideoPlayer create(
@NonNull SurfaceProducer surfaceProducer,
@NonNull VideoAsset asset,
@NonNull VideoPlayerOptions options) {
return new TextureVideoPlayer(

TextureVideoPlayer player = new TextureVideoPlayer(
events,
surfaceProducer,
asset.getMediaItem(),
options,
() -> {
androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector =
new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context);
ExoPlayer.Builder builder =
new ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setMediaSourceFactory(asset.getMediaSourceFactory(context));
return builder.build();
});
() -> VideoPlayer.createAbrExoPlayer(context, asset));

return player;
}

// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
Expand Down
Loading