Skip to content

Commit

Permalink
[video_player_android] Add RTSP support (#7081)
Browse files Browse the repository at this point in the history
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: flutter/flutter#18061 .

I added a RTSP tab on the example app:

https://github.com/flutter/packages/assets/7874200/9f0addb1-f6bb-4ec6-b8ad-e889f7d8b154
  • Loading branch information
beroso authored Jul 29, 2024
1 parent e3b8127 commit 99e8606
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 19 deletions.
1 change: 1 addition & 0 deletions packages/video_player/video_player_android/AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ Anton Borries <[email protected]>
Alex Li <[email protected]>
Rahul Raj <[email protected]>
Márton Matuz <[email protected]>
André Sousa <[email protected]>
4 changes: 4 additions & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.6.0

* Adds RTSP support.

## 2.5.4

* Updates Media3-ExoPlayer to 1.4.0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ android {
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -79,8 +79,8 @@ MediaSource.Factory getMediaSourceFactory(
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,21 @@ static VideoAsset fromRemoteUrl(
@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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public void initialize() {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() {

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");
Expand All @@ -89,8 +89,8 @@ public void remoteVideoOverridesUserAgentIfProvided() {

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");
Expand Down Expand Up @@ -127,12 +127,28 @@ public void remoteVideoSetsAdditionalHttpHeadersIfProvided() {

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:[email protected]/stream");
MediaItem mediaItem = asset.getMediaItem();

assert mediaItem.localConfiguration != null;
assertEquals(
mediaItem.localConfiguration.uri, Uri.parse("rtsp://test:[email protected]/stream"));
}
}
96 changes: 89 additions & 7 deletions packages/video_player/video_player_android/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,24 @@ class _App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
length: 3,
child: Scaffold(
key: const ValueKey<String>('home_page'),
appBar: AppBar(
title: const Text('Video player example'),
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'),
],
),
),
body: TabBarView(
children: <Widget>[
_BumbleBeeRemoteVideo(),
_RtspRemoteVideo(),
_ButterFlyAssetVideo(),
],
),
Expand All @@ -63,8 +62,7 @@ class _ButterFlyAssetVideoState extends State<_ButterFlyAssetVideo> {
_controller.addListener(() {
setState(() {});
});
_controller.initialize().then((_) => setState(() {}));
_controller.play();
_controller.initialize().then((_) => _controller.play());
}

@override
Expand Down Expand Up @@ -156,6 +154,90 @@ class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> {
}
}

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});

Expand Down
2 changes: 1 addition & 1 deletion packages/video_player/video_player_android/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: video_player_android
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
Expand Down

0 comments on commit 99e8606

Please sign in to comment.