Skip to content

Commit 1612774

Browse files
authored
Final refactor of video_player_android before SurfaceProducer#setCallback. (#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.
1 parent eb0e54a commit 1612774

File tree

12 files changed

+859
-404
lines changed

12 files changed

+859
-404
lines changed

packages/video_player/video_player_android/android/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ android {
5757
testImplementation 'androidx.test:core:1.3.0'
5858
testImplementation 'org.mockito:mockito-inline:5.0.0'
5959
testImplementation 'org.robolectric:robolectric:4.10.3'
60+
testImplementation "androidx.media3:media3-test-utils:1.3.1"
6061
}
6162

6263
testOptions {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.videoplayer;
6+
7+
import android.content.Context;
8+
import androidx.annotation.NonNull;
9+
import androidx.media3.common.MediaItem;
10+
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
11+
import androidx.media3.exoplayer.source.MediaSource;
12+
13+
final class LocalVideoAsset extends VideoAsset {
14+
LocalVideoAsset(@NonNull String assetUrl) {
15+
super(assetUrl);
16+
}
17+
18+
@NonNull
19+
@Override
20+
MediaItem getMediaItem() {
21+
return new MediaItem.Builder().setUri(assetUrl).build();
22+
}
23+
24+
@Override
25+
MediaSource.Factory getMediaSourceFactory(Context context) {
26+
return new DefaultMediaSourceFactory(context);
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.videoplayer;
6+
7+
import android.content.Context;
8+
import androidx.annotation.NonNull;
9+
import androidx.annotation.Nullable;
10+
import androidx.annotation.OptIn;
11+
import androidx.annotation.VisibleForTesting;
12+
import androidx.media3.common.MediaItem;
13+
import androidx.media3.common.MimeTypes;
14+
import androidx.media3.common.util.UnstableApi;
15+
import androidx.media3.datasource.DataSource;
16+
import androidx.media3.datasource.DefaultDataSource;
17+
import androidx.media3.datasource.DefaultHttpDataSource;
18+
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
19+
import androidx.media3.exoplayer.source.MediaSource;
20+
import java.util.Map;
21+
22+
final class RemoteVideoAsset extends VideoAsset {
23+
private static final String DEFAULT_USER_AGENT = "ExoPlayer";
24+
private static final String HEADER_USER_AGENT = "User-Agent";
25+
26+
@NonNull private final StreamingFormat streamingFormat;
27+
@NonNull private final Map<String, String> httpHeaders;
28+
29+
RemoteVideoAsset(
30+
@Nullable String assetUrl,
31+
@NonNull StreamingFormat streamingFormat,
32+
@NonNull Map<String, String> httpHeaders) {
33+
super(assetUrl);
34+
this.streamingFormat = streamingFormat;
35+
this.httpHeaders = httpHeaders;
36+
}
37+
38+
@NonNull
39+
@Override
40+
MediaItem getMediaItem() {
41+
MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl);
42+
String mimeType = null;
43+
switch (streamingFormat) {
44+
case SMOOTH:
45+
mimeType = MimeTypes.APPLICATION_SS;
46+
break;
47+
case DYNAMIC_ADAPTIVE:
48+
mimeType = MimeTypes.APPLICATION_MPD;
49+
break;
50+
case HTTP_LIVE:
51+
mimeType = MimeTypes.APPLICATION_M3U8;
52+
break;
53+
}
54+
if (mimeType != null) {
55+
builder.setMimeType(mimeType);
56+
}
57+
return builder.build();
58+
}
59+
60+
@Override
61+
MediaSource.Factory getMediaSourceFactory(Context context) {
62+
return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory());
63+
}
64+
65+
/**
66+
* Returns a configured media source factory, starting at the provided factory.
67+
*
68+
* <p>This method is provided for ease of testing without making real HTTP calls.
69+
*
70+
* @param context application context.
71+
* @param initialFactory initial factory, to be configured.
72+
* @return configured factory, or {@code null} if not needed for this asset type.
73+
*/
74+
@VisibleForTesting
75+
MediaSource.Factory getMediaSourceFactory(
76+
Context context, DefaultHttpDataSource.Factory initialFactory) {
77+
String userAgent = DEFAULT_USER_AGENT;
78+
if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) {
79+
userAgent = httpHeaders.get(HEADER_USER_AGENT);
80+
}
81+
unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent);
82+
DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, initialFactory);
83+
return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSoruceFactory);
84+
}
85+
86+
// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
87+
@OptIn(markerClass = UnstableApi.class)
88+
private static void unstableUpdateDataSourceFactory(
89+
@NonNull DefaultHttpDataSource.Factory factory,
90+
@NonNull Map<String, String> httpHeaders,
91+
@Nullable String userAgent) {
92+
factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true);
93+
if (!httpHeaders.isEmpty()) {
94+
factory.setDefaultRequestProperties(httpHeaders);
95+
}
96+
}
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.videoplayer;
6+
7+
import android.content.Context;
8+
import androidx.annotation.NonNull;
9+
import androidx.annotation.Nullable;
10+
import androidx.media3.common.MediaItem;
11+
import androidx.media3.exoplayer.source.MediaSource;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
15+
/** A video to be played by {@link VideoPlayer}. */
16+
abstract class VideoAsset {
17+
/**
18+
* Returns an asset from a local {@code asset:///} URL, i.e. an on-device asset.
19+
*
20+
* @param assetUrl local asset, beginning in {@code asset:///}.
21+
* @return the asset.
22+
*/
23+
@NonNull
24+
static VideoAsset fromAssetUrl(@NonNull String assetUrl) {
25+
if (!assetUrl.startsWith("asset:///")) {
26+
throw new IllegalArgumentException("assetUrl must start with 'asset:///'");
27+
}
28+
return new LocalVideoAsset(assetUrl);
29+
}
30+
31+
/**
32+
* Returns an asset from a remote URL.
33+
*
34+
* @param remoteUrl remote asset, i.e. typically beginning with {@code https://} or similar.
35+
* @param streamingFormat which streaming format, provided as a hint if able.
36+
* @param httpHeaders HTTP headers to set for a request.
37+
* @return the asset.
38+
*/
39+
@NonNull
40+
static VideoAsset fromRemoteUrl(
41+
@Nullable String remoteUrl,
42+
@NonNull StreamingFormat streamingFormat,
43+
@NonNull Map<String, String> httpHeaders) {
44+
return new RemoteVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
45+
}
46+
47+
@Nullable protected final String assetUrl;
48+
49+
protected VideoAsset(@Nullable String assetUrl) {
50+
this.assetUrl = assetUrl;
51+
}
52+
53+
/**
54+
* Returns the configured media item to be played.
55+
*
56+
* @return media item.
57+
*/
58+
@NonNull
59+
abstract MediaItem getMediaItem();
60+
61+
/**
62+
* Returns the configured media source factory, if needed for this asset type.
63+
*
64+
* @param context application context.
65+
* @return configured factory, or {@code null} if not needed for this asset type.
66+
*/
67+
abstract MediaSource.Factory getMediaSourceFactory(Context context);
68+
69+
/** Streaming formats that can be provided to the video player as a hint. */
70+
enum StreamingFormat {
71+
/** Default, if the format is either not known or not another valid format. */
72+
UNKNOWN,
73+
74+
/** Smooth Streaming. */
75+
SMOOTH,
76+
77+
/** MPEG-DASH (Dynamic Adaptive over HTTP). */
78+
DYNAMIC_ADAPTIVE,
79+
80+
/** HTTP Live Streaming (HLS). */
81+
HTTP_LIVE
82+
}
83+
}

packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java

+23-104
Original file line numberDiff line numberDiff line change
@@ -10,98 +10,59 @@
1010
import android.content.Context;
1111
import android.view.Surface;
1212
import androidx.annotation.NonNull;
13-
import androidx.annotation.Nullable;
14-
import androidx.annotation.OptIn;
1513
import androidx.annotation.VisibleForTesting;
1614
import androidx.media3.common.AudioAttributes;
1715
import androidx.media3.common.C;
1816
import androidx.media3.common.MediaItem;
19-
import androidx.media3.common.MimeTypes;
2017
import androidx.media3.common.PlaybackParameters;
21-
import androidx.media3.common.util.UnstableApi;
22-
import androidx.media3.datasource.DataSource;
23-
import androidx.media3.datasource.DefaultDataSource;
24-
import androidx.media3.datasource.DefaultHttpDataSource;
2518
import androidx.media3.exoplayer.ExoPlayer;
26-
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
2719
import io.flutter.view.TextureRegistry;
28-
import java.util.Map;
2920

3021
final class VideoPlayer {
31-
private static final String FORMAT_SS = "ss";
32-
private static final String FORMAT_DASH = "dash";
33-
private static final String FORMAT_HLS = "hls";
34-
private static final String FORMAT_OTHER = "other";
35-
3622
private ExoPlayer exoPlayer;
37-
3823
private Surface surface;
39-
4024
private final TextureRegistry.SurfaceTextureEntry textureEntry;
41-
4225
private final VideoPlayerCallbacks videoPlayerEvents;
43-
44-
private static final String USER_AGENT = "User-Agent";
45-
4626
private final VideoPlayerOptions options;
4727

48-
private final DefaultHttpDataSource.Factory httpDataSourceFactory;
49-
50-
VideoPlayer(
28+
/**
29+
* Creates a video player.
30+
*
31+
* @param context application context.
32+
* @param events event callbacks.
33+
* @param textureEntry texture to render to.
34+
* @param asset asset to play.
35+
* @param options options for playback.
36+
* @return a video player instance.
37+
*/
38+
@NonNull
39+
static VideoPlayer create(
5140
Context context,
5241
VideoPlayerCallbacks events,
5342
TextureRegistry.SurfaceTextureEntry textureEntry,
54-
String dataSource,
55-
String formatHint,
56-
@NonNull Map<String, String> httpHeaders,
43+
VideoAsset asset,
5744
VideoPlayerOptions options) {
58-
this.videoPlayerEvents = events;
59-
this.textureEntry = textureEntry;
60-
this.options = options;
61-
62-
MediaItem mediaItem =
63-
new MediaItem.Builder()
64-
.setUri(dataSource)
65-
.setMimeType(mimeFromFormatHint(formatHint))
66-
.build();
67-
68-
httpDataSourceFactory = new DefaultHttpDataSource.Factory();
69-
configureHttpDataSourceFactory(httpHeaders);
70-
71-
ExoPlayer exoPlayer = buildExoPlayer(context, httpDataSourceFactory);
72-
73-
exoPlayer.setMediaItem(mediaItem);
74-
exoPlayer.prepare();
75-
76-
setUpVideoPlayer(exoPlayer);
45+
ExoPlayer.Builder builder =
46+
new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context));
47+
return new VideoPlayer(builder, events, textureEntry, asset.getMediaItem(), options);
7748
}
7849

79-
// Constructor used to directly test members of this class.
8050
@VisibleForTesting
8151
VideoPlayer(
82-
ExoPlayer exoPlayer,
52+
ExoPlayer.Builder builder,
8353
VideoPlayerCallbacks events,
8454
TextureRegistry.SurfaceTextureEntry textureEntry,
85-
VideoPlayerOptions options,
86-
DefaultHttpDataSource.Factory httpDataSourceFactory) {
55+
MediaItem mediaItem,
56+
VideoPlayerOptions options) {
8757
this.videoPlayerEvents = events;
8858
this.textureEntry = textureEntry;
8959
this.options = options;
90-
this.httpDataSourceFactory = httpDataSourceFactory;
9160

92-
setUpVideoPlayer(exoPlayer);
93-
}
61+
ExoPlayer exoPlayer = builder.build();
62+
exoPlayer.setMediaItem(mediaItem);
63+
exoPlayer.prepare();
9464

95-
@VisibleForTesting
96-
public void configureHttpDataSourceFactory(@NonNull Map<String, String> httpHeaders) {
97-
final boolean httpHeadersNotEmpty = !httpHeaders.isEmpty();
98-
final String userAgent =
99-
httpHeadersNotEmpty && httpHeaders.containsKey(USER_AGENT)
100-
? httpHeaders.get(USER_AGENT)
101-
: "ExoPlayer";
102-
103-
unstableUpdateDataSourceFactory(
104-
httpDataSourceFactory, httpHeaders, userAgent, httpHeadersNotEmpty);
65+
setUpVideoPlayer(exoPlayer);
10566
}
10667

10768
private void setUpVideoPlayer(ExoPlayer exoPlayer) {
@@ -165,46 +126,4 @@ void dispose() {
165126
exoPlayer.release();
166127
}
167128
}
168-
169-
@NonNull
170-
private static ExoPlayer buildExoPlayer(
171-
Context context, DataSource.Factory baseDataSourceFactory) {
172-
DataSource.Factory dataSourceFactory =
173-
new DefaultDataSource.Factory(context, baseDataSourceFactory);
174-
DefaultMediaSourceFactory mediaSourceFactory =
175-
new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory);
176-
return new ExoPlayer.Builder(context).setMediaSourceFactory(mediaSourceFactory).build();
177-
}
178-
179-
@Nullable
180-
private static String mimeFromFormatHint(@Nullable String formatHint) {
181-
if (formatHint == null) {
182-
return null;
183-
}
184-
switch (formatHint) {
185-
case FORMAT_SS:
186-
return MimeTypes.APPLICATION_SS;
187-
case FORMAT_DASH:
188-
return MimeTypes.APPLICATION_MPD;
189-
case FORMAT_HLS:
190-
return MimeTypes.APPLICATION_M3U8;
191-
case FORMAT_OTHER:
192-
default:
193-
return null;
194-
}
195-
}
196-
197-
// TODO: migrate to stable API, see https://github.com/flutter/flutter/issues/147039
198-
@OptIn(markerClass = UnstableApi.class)
199-
private static void unstableUpdateDataSourceFactory(
200-
DefaultHttpDataSource.Factory factory,
201-
@NonNull Map<String, String> httpHeaders,
202-
String userAgent,
203-
boolean httpHeadersNotEmpty) {
204-
factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true);
205-
206-
if (httpHeadersNotEmpty) {
207-
factory.setDefaultRequestProperties(httpHeaders);
208-
}
209-
}
210129
}

packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public void onError(@NonNull String code, @Nullable String message, @Nullable Ob
9797
@Override
9898
public void onIsPlayingStateUpdate(boolean isPlaying) {
9999
Map<String, Object> event = new HashMap<>();
100+
event.put("event", "isPlayingStateUpdate");
100101
event.put("isPlaying", isPlaying);
101102
eventSink.success(event);
102103
}

0 commit comments

Comments
 (0)