Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Final refactor of video_player_android before SurfaceProducer#setCallback. #6982

Merged
merged 8 commits into from
Jun 25, 2024
Merged
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
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think the enum values generally are all caps in Java

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Done.

/** 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not worth blocking anything but if you agree and want to change:

Seems a bit odd to me to do half the setup of exoPlayer out of setUpVideoPlayer and half of it in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This will change (for the better) in the next PR.

}

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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this event on top of isPlaying?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually a regression in a previous refactor, we just haven't made another release since they have been internal refactors only. In other words, all events contain an event key-value pair and I made a mistake!

event.put("isPlaying", isPlaying);
eventSink.success(event);
}
Expand Down
Loading