Skip to content

Commit

Permalink
Final refactor of video_player_android before `SurfaceProducer#setC…
Browse files Browse the repository at this point in the history
…allback`. (#6982)

I'm working on re-landing #6456,
this time without using the `ActivityAware` interface (see
flutter/flutter#148417). As part of that work,
I'll need to better control the `ExoPlayer` lifecycle and save/restore
internal state.

Follows the patterns of some of the previous PRs, i.e.

- #6922
- #6908

The changes in this PR are _mostly_ tests, it was extremely difficult to
just add more tests to the already very leaky `VideoPlayer` abstraction
which had lots of `@VisibleForTesting` methods and other "holes" to
observe state. This PR removes all of that, and adds test coverage where
it was missing.

Namely it:

- Adds a new class, `VideoAsset`, that builds and configures the media
that `ExoPlayer` uses.
- Removes all "testing" state from `VidePlayer`, keeping it nearly
immutable.
- Added tests for most of the classes I've added since, which were
mostly missing.

That being said, this is a large change. I'm happy to sit down with
either of you and walk through it.

---

Opening as a draft for the moment, since there is a pubspec change
needing I want to handle first.
  • Loading branch information
matanlurey authored Jun 25, 2024
1 parent eb0e54a commit 1612774
Show file tree
Hide file tree
Showing 12 changed files with 859 additions and 404 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ android {
testImplementation 'androidx.test:core:1.3.0'
testImplementation 'org.mockito:mockito-inline:5.0.0'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation "androidx.media3:media3-test-utils:1.3.1"
}

testOptions {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugins.videoplayer;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.media3.common.MediaItem;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.MediaSource;

final class LocalVideoAsset extends VideoAsset {
LocalVideoAsset(@NonNull String assetUrl) {
super(assetUrl);
}

@NonNull
@Override
MediaItem getMediaItem() {
return new MediaItem.Builder().setUri(assetUrl).build();
}

@Override
MediaSource.Factory getMediaSourceFactory(Context context) {
return new DefaultMediaSourceFactory(context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugins.videoplayer;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.datasource.DefaultHttpDataSource;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.MediaSource;
import java.util.Map;

final class RemoteVideoAsset extends VideoAsset {
private static final String DEFAULT_USER_AGENT = "ExoPlayer";
private static final String HEADER_USER_AGENT = "User-Agent";

@NonNull private final StreamingFormat streamingFormat;
@NonNull private final Map<String, String> httpHeaders;

RemoteVideoAsset(
@Nullable String assetUrl,
@NonNull StreamingFormat streamingFormat,
@NonNull Map<String, String> httpHeaders) {
super(assetUrl);
this.streamingFormat = streamingFormat;
this.httpHeaders = httpHeaders;
}

@NonNull
@Override
MediaItem getMediaItem() {
MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl);
String mimeType = null;
switch (streamingFormat) {
case SMOOTH:
mimeType = MimeTypes.APPLICATION_SS;
break;
case DYNAMIC_ADAPTIVE:
mimeType = MimeTypes.APPLICATION_MPD;
break;
case HTTP_LIVE:
mimeType = MimeTypes.APPLICATION_M3U8;
break;
}
if (mimeType != null) {
builder.setMimeType(mimeType);
}
return builder.build();
}

@Override
MediaSource.Factory getMediaSourceFactory(Context context) {
return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory());
}

/**
* Returns a configured media source factory, starting at the provided factory.
*
* <p>This method is provided for ease of testing without making real HTTP calls.
*
* @param context application context.
* @param initialFactory initial factory, to be configured.
* @return configured factory, or {@code null} if not needed for this asset type.
*/
@VisibleForTesting
MediaSource.Factory getMediaSourceFactory(
Context context, DefaultHttpDataSource.Factory initialFactory) {
String userAgent = DEFAULT_USER_AGENT;
if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) {
userAgent = httpHeaders.get(HEADER_USER_AGENT);
}
unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent);
DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, initialFactory);
return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSoruceFactory);
}

// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
@OptIn(markerClass = UnstableApi.class)
private static void unstableUpdateDataSourceFactory(
@NonNull DefaultHttpDataSource.Factory factory,
@NonNull Map<String, String> httpHeaders,
@Nullable String userAgent) {
factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true);
if (!httpHeaders.isEmpty()) {
factory.setDefaultRequestProperties(httpHeaders);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugins.videoplayer;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.exoplayer.source.MediaSource;
import java.util.HashMap;
import java.util.Map;

/** A video to be played by {@link VideoPlayer}. */
abstract class VideoAsset {
/**
* Returns an asset from a local {@code asset:///} URL, i.e. an on-device asset.
*
* @param assetUrl local asset, beginning in {@code asset:///}.
* @return the asset.
*/
@NonNull
static VideoAsset fromAssetUrl(@NonNull String assetUrl) {
if (!assetUrl.startsWith("asset:///")) {
throw new IllegalArgumentException("assetUrl must start with 'asset:///'");
}
return new LocalVideoAsset(assetUrl);
}

/**
* Returns an asset from a remote URL.
*
* @param remoteUrl remote asset, i.e. typically beginning with {@code https://} or similar.
* @param streamingFormat which streaming format, provided as a hint if able.
* @param httpHeaders HTTP headers to set for a request.
* @return the asset.
*/
@NonNull
static VideoAsset fromRemoteUrl(
@Nullable String remoteUrl,
@NonNull StreamingFormat streamingFormat,
@NonNull Map<String, String> httpHeaders) {
return new RemoteVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
}

@Nullable protected final String assetUrl;

protected VideoAsset(@Nullable String assetUrl) {
this.assetUrl = assetUrl;
}

/**
* Returns the configured media item to be played.
*
* @return media item.
*/
@NonNull
abstract MediaItem getMediaItem();

/**
* Returns the configured media source factory, if needed for this asset type.
*
* @param context application context.
* @return configured factory, or {@code null} if not needed for this asset type.
*/
abstract MediaSource.Factory getMediaSourceFactory(Context context);

/** Streaming formats that can be provided to the video player as a hint. */
enum StreamingFormat {
/** Default, if the format is either not known or not another valid format. */
UNKNOWN,

/** Smooth Streaming. */
SMOOTH,

/** MPEG-DASH (Dynamic Adaptive over HTTP). */
DYNAMIC_ADAPTIVE,

/** HTTP Live Streaming (HLS). */
HTTP_LIVE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,98 +10,59 @@
import android.content.Context;
import android.view.Surface;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.datasource.DefaultHttpDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import io.flutter.view.TextureRegistry;
import java.util.Map;

final class VideoPlayer {
private static final String FORMAT_SS = "ss";
private static final String FORMAT_DASH = "dash";
private static final String FORMAT_HLS = "hls";
private static final String FORMAT_OTHER = "other";

private ExoPlayer exoPlayer;

private Surface surface;

private final TextureRegistry.SurfaceTextureEntry textureEntry;

private final VideoPlayerCallbacks videoPlayerEvents;

private static final String USER_AGENT = "User-Agent";

private final VideoPlayerOptions options;

private final DefaultHttpDataSource.Factory httpDataSourceFactory;

VideoPlayer(
/**
* Creates a video player.
*
* @param context application context.
* @param events event callbacks.
* @param textureEntry texture to render to.
* @param asset asset to play.
* @param options options for playback.
* @return a video player instance.
*/
@NonNull
static VideoPlayer create(
Context context,
VideoPlayerCallbacks events,
TextureRegistry.SurfaceTextureEntry textureEntry,
String dataSource,
String formatHint,
@NonNull Map<String, String> httpHeaders,
VideoAsset asset,
VideoPlayerOptions options) {
this.videoPlayerEvents = events;
this.textureEntry = textureEntry;
this.options = options;

MediaItem mediaItem =
new MediaItem.Builder()
.setUri(dataSource)
.setMimeType(mimeFromFormatHint(formatHint))
.build();

httpDataSourceFactory = new DefaultHttpDataSource.Factory();
configureHttpDataSourceFactory(httpHeaders);

ExoPlayer exoPlayer = buildExoPlayer(context, httpDataSourceFactory);

exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();

setUpVideoPlayer(exoPlayer);
ExoPlayer.Builder builder =
new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context));
return new VideoPlayer(builder, events, textureEntry, asset.getMediaItem(), options);
}

// Constructor used to directly test members of this class.
@VisibleForTesting
VideoPlayer(
ExoPlayer exoPlayer,
ExoPlayer.Builder builder,
VideoPlayerCallbacks events,
TextureRegistry.SurfaceTextureEntry textureEntry,
VideoPlayerOptions options,
DefaultHttpDataSource.Factory httpDataSourceFactory) {
MediaItem mediaItem,
VideoPlayerOptions options) {
this.videoPlayerEvents = events;
this.textureEntry = textureEntry;
this.options = options;
this.httpDataSourceFactory = httpDataSourceFactory;

setUpVideoPlayer(exoPlayer);
}
ExoPlayer exoPlayer = builder.build();
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();

@VisibleForTesting
public void configureHttpDataSourceFactory(@NonNull Map<String, String> httpHeaders) {
final boolean httpHeadersNotEmpty = !httpHeaders.isEmpty();
final String userAgent =
httpHeadersNotEmpty && httpHeaders.containsKey(USER_AGENT)
? httpHeaders.get(USER_AGENT)
: "ExoPlayer";

unstableUpdateDataSourceFactory(
httpDataSourceFactory, httpHeaders, userAgent, httpHeadersNotEmpty);
setUpVideoPlayer(exoPlayer);
}

private void setUpVideoPlayer(ExoPlayer exoPlayer) {
Expand Down Expand Up @@ -165,46 +126,4 @@ void dispose() {
exoPlayer.release();
}
}

@NonNull
private static ExoPlayer buildExoPlayer(
Context context, DataSource.Factory baseDataSourceFactory) {
DataSource.Factory dataSourceFactory =
new DefaultDataSource.Factory(context, baseDataSourceFactory);
DefaultMediaSourceFactory mediaSourceFactory =
new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory);
return new ExoPlayer.Builder(context).setMediaSourceFactory(mediaSourceFactory).build();
}

@Nullable
private static String mimeFromFormatHint(@Nullable String formatHint) {
if (formatHint == null) {
return null;
}
switch (formatHint) {
case FORMAT_SS:
return MimeTypes.APPLICATION_SS;
case FORMAT_DASH:
return MimeTypes.APPLICATION_MPD;
case FORMAT_HLS:
return MimeTypes.APPLICATION_M3U8;
case FORMAT_OTHER:
default:
return null;
}
}

// TODO: migrate to stable API, see https://github.com/flutter/flutter/issues/147039
@OptIn(markerClass = UnstableApi.class)
private static void unstableUpdateDataSourceFactory(
DefaultHttpDataSource.Factory factory,
@NonNull Map<String, String> httpHeaders,
String userAgent,
boolean httpHeadersNotEmpty) {
factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true);

if (httpHeadersNotEmpty) {
factory.setDefaultRequestProperties(httpHeaders);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public void onError(@NonNull String code, @Nullable String message, @Nullable Ob
@Override
public void onIsPlayingStateUpdate(boolean isPlaying) {
Map<String, Object> event = new HashMap<>();
event.put("event", "isPlayingStateUpdate");
event.put("isPlaying", isPlaying);
eventSink.success(event);
}
Expand Down
Loading

0 comments on commit 1612774

Please sign in to comment.