[video_player_android] Add RTSP support (#7081)
Add RTSP support to `DataSourceType.network` videos on Android platform.
I'm using this patch on my projects and it works well, but I need some feedback if the approach used is correct. If so, I will continue writing the tests.
This PR implements the Android part of this feature request: https://github.com/flutter/flutter/issues/18061 .
I added a RTSP tab on the example app:
https://github.com/flutter/packages/assets/7874200/9f0addb1-f6bb-4ec6-b8ad-e889f7d8b154
diff --git a/packages/video_player/video_player_android/AUTHORS b/packages/video_player/video_player_android/AUTHORS
index fc16c35..07a1e9f 100644
--- a/packages/video_player/video_player_android/AUTHORS
+++ b/packages/video_player/video_player_android/AUTHORS
@@ -65,3 +65,4 @@
Alex Li <google@alexv525.com>
Rahul Raj <64.rahulraj@gmail.com>
Márton Matuz <matuzmarci@gmail.com>
+André Sousa <andrelvsousa@gmail.com>
diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md
index 911555a..e6815fc 100644
--- a/packages/video_player/video_player_android/CHANGELOG.md
+++ b/packages/video_player/video_player_android/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.6.0
+
+* Adds RTSP support.
+
## 2.5.4
* Updates Media3-ExoPlayer to 1.4.0.
diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle
index d9ec79e..af50083 100644
--- a/packages/video_player/video_player_android/android/build.gradle
+++ b/packages/video_player/video_player_android/android/build.gradle
@@ -52,6 +52,7 @@
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer-hls:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer-dash:${exoplayer_version}"
+ implementation "androidx.media3:media3-exoplayer-rtsp:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer-smoothstreaming:${exoplayer_version}"
testImplementation 'junit:junit:4.13.2'
testImplementation 'androidx.test:core:1.3.0'
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
similarity index 95%
rename from packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java
rename to packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
index 75c3c42..f29efc3 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
@@ -19,14 +19,14 @@
import androidx.media3.exoplayer.source.MediaSource;
import java.util.Map;
-final class RemoteVideoAsset extends VideoAsset {
+final class HttpVideoAsset 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(
+ HttpVideoAsset(
@Nullable String assetUrl,
@NonNull StreamingFormat streamingFormat,
@NonNull Map<String, String> httpHeaders) {
@@ -79,8 +79,8 @@
userAgent = httpHeaders.get(HEADER_USER_AGENT);
}
unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent);
- DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, initialFactory);
- return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSoruceFactory);
+ DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, initialFactory);
+ return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory);
}
// TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java
new file mode 100644
index 0000000..1eb87c8
--- /dev/null
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java
@@ -0,0 +1,32 @@
+// 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.OptIn;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.exoplayer.rtsp.RtspMediaSource;
+import androidx.media3.exoplayer.source.MediaSource;
+
+final class RtspVideoAsset extends VideoAsset {
+ RtspVideoAsset(@NonNull String assetUrl) {
+ super(assetUrl);
+ }
+
+ @NonNull
+ @Override
+ MediaItem getMediaItem() {
+ return new MediaItem.Builder().setUri(assetUrl).build();
+ }
+
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @OptIn(markerClass = UnstableApi.class)
+ @Override
+ MediaSource.Factory getMediaSourceFactory(Context context) {
+ return new RtspMediaSource.Factory();
+ }
+}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
index 2b83437..3fab758 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
@@ -41,7 +41,21 @@
@Nullable String remoteUrl,
@NonNull StreamingFormat streamingFormat,
@NonNull Map<String, String> httpHeaders) {
- return new RemoteVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
+ return new HttpVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
+ }
+
+ /**
+ * Returns an asset from a RTSP URL.
+ *
+ * @param rtspUrl remote asset, beginning with {@code rtsp://}.
+ * @return the asset.
+ */
+ @NonNull
+ static VideoAsset fromRtspUrl(@NonNull String rtspUrl) {
+ if (!rtspUrl.startsWith("rtsp://")) {
+ throw new IllegalArgumentException("rtspUrl must start with 'rtsp://'");
+ }
+ return new RtspVideoAsset(rtspUrl);
}
@Nullable protected final String assetUrl;
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
index 0e57068..af13c53 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
@@ -110,6 +110,8 @@
assetLookupKey = flutterState.keyForAsset.get(arg.getAsset());
}
videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey);
+ } else if (arg.getUri().startsWith("rtsp://")) {
+ videoAsset = VideoAsset.fromRtspUrl(arg.getUri());
} else {
Map<String, String> httpHeaders = arg.getHttpHeaders();
VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN;
diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
index 743bf57..6e95d05 100644
--- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
+++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
@@ -69,8 +69,8 @@
DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
- // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
- ((RemoteVideoAsset) asset)
+ // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+ ((HttpVideoAsset) asset)
.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
verify(mockFactory).setUserAgent("ExoPlayer");
@@ -89,8 +89,8 @@
DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
- // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
- ((RemoteVideoAsset) asset)
+ // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+ ((HttpVideoAsset) asset)
.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
verify(mockFactory).setUserAgent("FantasticalVideoBot");
@@ -127,12 +127,28 @@
DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
- // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
- ((RemoteVideoAsset) asset)
+ // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+ ((HttpVideoAsset) asset)
.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
verify(mockFactory).setUserAgent("ExoPlayer");
verify(mockFactory).setAllowCrossProtocolRedirects(true);
verify(mockFactory).setDefaultRequestProperties(headers);
}
+
+ @Test
+ public void rtspVideoRequiresRtspUrl() {
+ assertThrows(
+ IllegalArgumentException.class, () -> VideoAsset.fromRtspUrl("https://not.rtsp/video.mp4"));
+ }
+
+ @Test
+ public void rtspVideoCreatesMediaItem() {
+ VideoAsset asset = VideoAsset.fromRtspUrl("rtsp://test:pass@flutter.dev/stream");
+ MediaItem mediaItem = asset.getMediaItem();
+
+ assert mediaItem.localConfiguration != null;
+ assertEquals(
+ mediaItem.localConfiguration.uri, Uri.parse("rtsp://test:pass@flutter.dev/stream"));
+ }
}
diff --git a/packages/video_player/video_player_android/example/lib/main.dart b/packages/video_player/video_player_android/example/lib/main.dart
index df28b39..79f4963 100644
--- a/packages/video_player/video_player_android/example/lib/main.dart
+++ b/packages/video_player/video_player_android/example/lib/main.dart
@@ -20,7 +20,7 @@
@override
Widget build(BuildContext context) {
return DefaultTabController(
- length: 2,
+ length: 3,
child: Scaffold(
key: const ValueKey<String>('home_page'),
appBar: AppBar(
@@ -28,10 +28,8 @@
bottom: const TabBar(
isScrollable: true,
tabs: <Widget>[
- Tab(
- icon: Icon(Icons.cloud),
- text: 'Remote',
- ),
+ Tab(icon: Icon(Icons.cloud), text: 'Remote'),
+ Tab(icon: Icon(Icons.videocam), text: 'RTSP'),
Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'),
],
),
@@ -39,6 +37,7 @@
body: TabBarView(
children: <Widget>[
_BumbleBeeRemoteVideo(),
+ _RtspRemoteVideo(),
_ButterFlyAssetVideo(),
],
),
@@ -63,8 +62,7 @@
_controller.addListener(() {
setState(() {});
});
- _controller.initialize().then((_) => setState(() {}));
- _controller.play();
+ _controller.initialize().then((_) => _controller.play());
}
@override
@@ -156,6 +154,90 @@
}
}
+class _RtspRemoteVideo extends StatefulWidget {
+ @override
+ _RtspRemoteVideoState createState() => _RtspRemoteVideoState();
+}
+
+class _RtspRemoteVideoState extends State<_RtspRemoteVideo> {
+ MiniController? _controller;
+
+ @override
+ void dispose() {
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ Future<void> changeUrl(String url) async {
+ if (_controller != null) {
+ await _controller!.dispose();
+ }
+
+ setState(() {
+ _controller = MiniController.network(url);
+ });
+
+ _controller!.addListener(() {
+ setState(() {});
+ });
+
+ return _controller!.initialize();
+ }
+
+ String? _validateRtspUrl(String? value) {
+ if (value == null || !value.startsWith('rtsp://')) {
+ return 'Enter a valid RTSP URL';
+ }
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: <Widget>[
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('With RTSP streaming'),
+ Padding(
+ padding: const EdgeInsets.all(20.0),
+ child: TextFormField(
+ autovalidateMode: AutovalidateMode.onUserInteraction,
+ decoration: const InputDecoration(label: Text('RTSP URL')),
+ validator: _validateRtspUrl,
+ textInputAction: TextInputAction.done,
+ onFieldSubmitted: (String value) {
+ if (_validateRtspUrl(value) == null) {
+ changeUrl(value);
+ } else {
+ setState(() {
+ _controller?.dispose();
+ _controller = null;
+ });
+ }
+ },
+ ),
+ ),
+ if (_controller != null)
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller!.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: <Widget>[
+ VideoPlayer(_controller!),
+ _ControlsOverlay(controller: _controller!),
+ VideoProgressIndicator(_controller!),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _ControlsOverlay extends StatelessWidget {
const _ControlsOverlay({required this.controller});
diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml
index fee6349..ed629fa 100644
--- a/packages/video_player/video_player_android/pubspec.yaml
+++ b/packages/video_player/video_player_android/pubspec.yaml
@@ -2,7 +2,7 @@
description: Android implementation of the video_player plugin.
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
-version: 2.5.4
+version: 2.6.0
environment:
sdk: ^3.4.0