本地视频延迟优化+视频的获取链路播放
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
|
||||
@@ -35,6 +35,7 @@ import 'drone_station_detail_page.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import 'qr_scanner_page.dart';
|
||||
import 'bind_device_page.dart';
|
||||
import '../../../local_camera/presentation/pages/local_camera_page.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../devices/domain/entities/device_entity.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -1138,6 +1139,20 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
// TODO: 添加设备功能
|
||||
},
|
||||
),
|
||||
Divider(height: 1, color: ctx.appColors.divider),
|
||||
_buildMenuOption(
|
||||
ctx,
|
||||
icon: Icons.videocam_outlined,
|
||||
label: '本地视频',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LocalCameraPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
107
lib/features/v2/local_camera/data/lan_interface_selector.dart
Normal file
107
lib/features/v2/local_camera/data/lan_interface_selector.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// 局域网网卡筛选:本功能的摄像头固定通过 USB 以太网连接,
|
||||
/// 因此应当**只在以太网网卡所在的网段**做组播探测 / 网段扫描,
|
||||
/// 排除 WiFi(wlan*)、移动网络(rmnet*/ccmni* 等),既更快又不会扫到无关网段。
|
||||
class LanInterface {
|
||||
final String name;
|
||||
final String ip;
|
||||
final InternetAddress address;
|
||||
|
||||
LanInterface(this.name, this.ip, this.address);
|
||||
|
||||
/// 该 IPv4 的 /24 网段前缀(如 10.113.170)。
|
||||
String get prefix {
|
||||
final s = ip.split('.');
|
||||
return s.length == 4 ? '${s[0]}.${s[1]}.${s[2]}' : '';
|
||||
}
|
||||
}
|
||||
|
||||
/// WiFi / 移动网络 / 虚拟/环回 网卡的命名特征。
|
||||
bool _looksWifiOrCellular(String rawName) {
|
||||
final n = rawName.toLowerCase();
|
||||
const blockedPrefix = [
|
||||
'wlan',
|
||||
'ap',
|
||||
'rmnet',
|
||||
'ccmni',
|
||||
'gbr',
|
||||
'qrtr',
|
||||
'mobile',
|
||||
'vnet',
|
||||
'dummy',
|
||||
'lo',
|
||||
'tun',
|
||||
'tap',
|
||||
'ppp',
|
||||
'p2p',
|
||||
'wpan',
|
||||
'bt',
|
||||
'swlan',
|
||||
'utun',
|
||||
'ipsec',
|
||||
'starlink',
|
||||
];
|
||||
for (final p in blockedPrefix) {
|
||||
if (n.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 明确的以太网 / USB 网卡命名特征。
|
||||
bool _looksEthernet(String rawName) {
|
||||
final n = rawName.toLowerCase();
|
||||
return n.startsWith('eth') ||
|
||||
n.startsWith('en') ||
|
||||
n.startsWith('usb') ||
|
||||
n.contains('rndis') ||
|
||||
n.contains('asix') ||
|
||||
n.contains('ax88') ||
|
||||
n.contains('mtk_eth') ||
|
||||
n.contains('ether');
|
||||
}
|
||||
|
||||
/// 选取用于探测/扫描的网卡。
|
||||
///
|
||||
/// 策略:
|
||||
/// 1) 取全部非回环 IPv4 网卡;
|
||||
/// 2) 优先返回"名字像以太网且不是 WiFi/移动"的网卡;
|
||||
/// 3) 若没识别到以太网命名(很多安卓不把网卡名暴露给 App),退回"排除 WiFi/移动"后的网卡;
|
||||
/// 4) 若仍为空,返回全部,交由上层兜底。
|
||||
/// [ethernetOnly]=false 时直接返回全部网卡。
|
||||
Future<List<LanInterface>> selectLanInterfaces({bool ethernetOnly = true}) async {
|
||||
final raw = <LanInterface>[];
|
||||
try {
|
||||
final ifs = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
for (final i in ifs) {
|
||||
for (final a in i.addresses) {
|
||||
final ipStr = a.address.toString();
|
||||
InternetAddress addr;
|
||||
try {
|
||||
addr = a.address is InternetAddress
|
||||
? a.address as InternetAddress
|
||||
: InternetAddress(ipStr);
|
||||
} catch (_) {
|
||||
addr = InternetAddress(ipStr);
|
||||
}
|
||||
raw.add(LanInterface(i.name, ipStr, addr));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
if (!ethernetOnly) return raw;
|
||||
|
||||
final eth = raw
|
||||
.where((e) => _looksEthernet(e.name) && !_looksWifiOrCellular(e.name))
|
||||
.toList();
|
||||
if (eth.isNotEmpty) return eth;
|
||||
|
||||
// 命名未识别到以太网:退回"排除 WiFi/移动"后的其余网卡(通常就是以太网适配器)
|
||||
final others = raw.where((e) => !_looksWifiOrCellular(e.name)).toList();
|
||||
return others.isNotEmpty ? others : raw;
|
||||
}
|
||||
280
lib/features/v2/local_camera/data/onvif_client.dart
Normal file
280
lib/features/v2/local_camera/data/onvif_client.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// 轻量 ONVIF SOAP 客户端。默认账号 admin、密码为空(构造函数可覆盖)。
|
||||
/// 提供 GetDeviceInformation / GetCapabilities(Media XAddr) / GetProfiles / GetStreamUri。
|
||||
/// 认证采用 WS-Security UsernameToken + PasswordDigest。
|
||||
class OnvifClient {
|
||||
final String username;
|
||||
final String password;
|
||||
final Random _rnd = Random.secure();
|
||||
|
||||
OnvifClient({this.username = 'admin', this.password = ''});
|
||||
|
||||
String _uuid() {
|
||||
final bytes = List<int>.generate(16, (_) => _rnd.nextInt(256));
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return 'urn:uuid:${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
|
||||
}
|
||||
|
||||
/// WS-Security UsernameToken + PasswordDigest。
|
||||
/// 无用户名则不加安全头(匿名)。密码为空时仍按规范计算摘要(Nonce+Created+"")。
|
||||
String _wsseHeader() {
|
||||
if (username.isEmpty) return '';
|
||||
final nonceBytes = List<int>.generate(16, (_) => _rnd.nextInt(256));
|
||||
final nonceB64 = base64.encode(nonceBytes);
|
||||
final created = DateTime.now()
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll('.', '')
|
||||
.replaceAll('+00:00', 'Z');
|
||||
// digest = Base64(SHA1(Nonce + Created + Password))
|
||||
final digestInput = <int>[
|
||||
...nonceBytes,
|
||||
...utf8.encode(created),
|
||||
...utf8.encode(password),
|
||||
];
|
||||
final passwordDigest = base64.encode(sha1.convert(digestInput).bytes);
|
||||
|
||||
return '''<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
|
||||
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|
||||
<wsse:UsernameToken>
|
||||
<wsse:Username>$username</wsse:Username>
|
||||
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">$passwordDigest</wsse:Password>
|
||||
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">$nonceB64</wsse:Nonce>
|
||||
<wsu:Created>$created</wsu:Created>
|
||||
</wsse:UsernameToken>
|
||||
</wsse:Security>''';
|
||||
}
|
||||
|
||||
String _envelope({
|
||||
required String serviceNs,
|
||||
required String actionTail,
|
||||
required String bodyInner,
|
||||
}) {
|
||||
final action = '$serviceNs/$actionTail';
|
||||
return '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:a="http://www.w3.org/2005/08/addressing">
|
||||
<s:Header>
|
||||
<a:Action s:mustUnderstand="1">$action</a:Action>
|
||||
<a:MessageID>${_uuid()}</a:MessageID>
|
||||
${_wsseHeader()}
|
||||
</s:Header>
|
||||
<s:Body>$bodyInner</s:Body>
|
||||
</s:Envelope>''';
|
||||
}
|
||||
|
||||
Future<String> _post(String url, String body) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
|
||||
try {
|
||||
final req = await client.postUrl(Uri.parse(url));
|
||||
req.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'application/soap+xml; charset=utf-8',
|
||||
);
|
||||
req.add(utf8.encode(body));
|
||||
final resp = await req.close().timeout(const Duration(seconds: 8));
|
||||
final text = await resp.transform(utf8.decoder).join();
|
||||
debugPrint(
|
||||
'📡 [ONVIF] POST $url → ${resp.statusCode} · ${_oneLine(text, 600)}',
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw OnvifException('ONVIF 服务返回状态码 ${resp.statusCode}');
|
||||
}
|
||||
return text;
|
||||
} on SocketException {
|
||||
debugPrint('📡 [ONVIF] POST $url → 无法连接');
|
||||
throw OnvifException('无法连接摄像头 ONVIF 服务,请检查网络');
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
static String _oneLine(String s, int max) {
|
||||
final flat = s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return flat.length <= max ? flat : '${flat.substring(0, max)}…';
|
||||
}
|
||||
|
||||
static String _firstText(XmlDocument doc, String name) {
|
||||
for (final e in doc.findAllElements(name)) {
|
||||
final t = e.innerText.trim();
|
||||
if (t.isNotEmpty) return t;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 取设备厂商/型号信息(用于列表展示)。
|
||||
Future<({String manufacturer, String model})> getDeviceInformation(
|
||||
String xAddr,
|
||||
) async {
|
||||
final body = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
|
||||
actionTail: 'GetDeviceInformation',
|
||||
bodyInner:
|
||||
'<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>',
|
||||
);
|
||||
final resp = await _post(xAddr, body);
|
||||
final doc = XmlDocument.parse(resp);
|
||||
return (
|
||||
manufacturer: _firstText(doc, 'Manufacturer'),
|
||||
model: _firstText(doc, 'Model'),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取 Media 服务地址。
|
||||
Future<String> getMediaXAddr(String deviceXAddr) async {
|
||||
final body = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
|
||||
actionTail: 'GetCapabilities',
|
||||
bodyInner:
|
||||
'<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Category>Media</tds:Category></tds:GetCapabilities>',
|
||||
);
|
||||
final resp = await _post(deviceXAddr, body);
|
||||
var media = _extractMediaXAddr(resp);
|
||||
if (media.isEmpty) {
|
||||
// 兑底:部分摄像头忽略/不支持 Category 参数,不带 Category 再取一次
|
||||
final allBody = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
|
||||
actionTail: 'GetCapabilities',
|
||||
bodyInner:
|
||||
'<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>',
|
||||
);
|
||||
final allResp = await _post(deviceXAddr, allBody);
|
||||
media = _extractMediaXAddr(allResp);
|
||||
}
|
||||
if (media.isEmpty) {
|
||||
throw OnvifException(
|
||||
'未获取到 Media 服务地址(设备可能未实现完整 ONVIF,或 admin/空密码鉴权未通过;详见上方 📡 原始响应日志)',
|
||||
);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
/// 从 GetCapabilities 响应中提取 Media 服务地址(优先 Media 节点下的 XAddr)。
|
||||
String _extractMediaXAddr(String resp) {
|
||||
final doc = XmlDocument.parse(resp);
|
||||
for (final media in doc.findAllElements('Media')) {
|
||||
for (final x in media.findElements('XAddr')) {
|
||||
final t = x.innerText.trim();
|
||||
if (t.isNotEmpty) return t;
|
||||
}
|
||||
}
|
||||
// 回退:任意 XAddr(很多设备只有 Media 一个服务地址)
|
||||
return _firstText(doc, 'XAddr');
|
||||
}
|
||||
|
||||
/// 获取第一个视频 Profile 的 token。
|
||||
Future<String> getFirstProfileToken(String mediaXAddr) async {
|
||||
final body = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
|
||||
actionTail: 'GetProfiles',
|
||||
bodyInner:
|
||||
'<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>',
|
||||
);
|
||||
final resp = await _post(mediaXAddr, body);
|
||||
final doc = XmlDocument.parse(resp);
|
||||
// Profiles 元素的 token 属性即 ProfileToken
|
||||
for (final p in doc.findAllElements('Profiles')) {
|
||||
final token = p.getAttribute('token');
|
||||
if (token != null && token.isNotEmpty) return token;
|
||||
}
|
||||
throw OnvifException('未获取到视频 Profile');
|
||||
}
|
||||
|
||||
/// 选择“分辨率最小”的视频 Profile(即子码流)以降低延迟与码率;
|
||||
/// 摄像头只报一个 Profile 或无分辨率信息时退回第一个。
|
||||
Future<String> getLowLatencyProfileToken(String mediaXAddr) async {
|
||||
final body = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
|
||||
actionTail: 'GetProfiles',
|
||||
bodyInner:
|
||||
'<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>',
|
||||
);
|
||||
final resp = await _post(mediaXAddr, body);
|
||||
final doc = XmlDocument.parse(resp);
|
||||
String? firstToken;
|
||||
String? bestToken;
|
||||
int bestArea = -1;
|
||||
for (final p in doc.findAllElements('Profiles')) {
|
||||
final token = p.getAttribute('token');
|
||||
if (token == null || token.isEmpty) continue;
|
||||
firstToken ??= token;
|
||||
final w = _firstIntIn(p, 'Width');
|
||||
final h = _firstIntIn(p, 'Height');
|
||||
if (w != null && h != null && w > 0 && h > 0) {
|
||||
final area = w * h;
|
||||
if (bestArea < 0 || area < bestArea) {
|
||||
bestArea = area;
|
||||
bestToken = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestToken != null) {
|
||||
debugPrint('🎚 [子码流] 选用最小分辨率 Profile(面积≈$bestArea)token=$bestToken');
|
||||
return bestToken;
|
||||
}
|
||||
if (firstToken != null) return firstToken;
|
||||
throw OnvifException('未获取到视频 Profile');
|
||||
}
|
||||
|
||||
/// 在 [parent] 及其后代中找第一个可解析为 int 的 [name] 元素值。
|
||||
int? _firstIntIn(XmlElement parent, String name) {
|
||||
for (final e in parent.findAllElements(name)) {
|
||||
final v = int.tryParse(e.innerText.trim());
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 获取 RTSP 地址(已注入访问凭证)。
|
||||
Future<String> getStreamUri(String mediaXAddr, String profileToken) async {
|
||||
final body = _envelope(
|
||||
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
|
||||
actionTail: 'GetStreamUri',
|
||||
bodyInner:
|
||||
'''<trt:GetStreamUri xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
<trt:StreamSetup><tt:Stream xmlns:tt="http://www.onvif.org/ver10/schema">RTP-Unicast</tt:Stream>
|
||||
<tt:Transport xmlns:tt="http://www.onvif.org/ver10/schema"><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>
|
||||
<trt:ProfileToken>$profileToken</trt:ProfileToken>
|
||||
</trt:GetStreamUri>''',
|
||||
);
|
||||
final resp = await _post(mediaXAddr, body);
|
||||
final doc = XmlDocument.parse(resp);
|
||||
final uri = _firstText(doc, 'Uri');
|
||||
if (uri.isEmpty) throw OnvifException('未获取到 RTSP 地址');
|
||||
return _injectCredentials(uri);
|
||||
}
|
||||
|
||||
/// 一步到位:由 device xAddr 得到带凭证的 RTSP 地址。
|
||||
Future<String> resolveRtsp(String deviceXAddr) async {
|
||||
final mediaXAddr = await getMediaXAddr(deviceXAddr);
|
||||
final token = await getLowLatencyProfileToken(mediaXAddr);
|
||||
return getStreamUri(mediaXAddr, token);
|
||||
}
|
||||
|
||||
/// rtsp://ip/path -> rtsp://admin:pass@ip/path。
|
||||
/// 关键:即便密码为空也必须保留冒号(拼成 'admin:@'),因为 video_player 的
|
||||
/// Android 后端 ExoPlayer(Media3) 是用 Uri.getUserInfo().split(':') 解析账号/密码,
|
||||
/// 写成 'admin@'(缺冒号)会解析不到密码字段导致 RTSP 鉴权失败。
|
||||
String _injectCredentials(String rtsp) {
|
||||
if (username.isEmpty) return rtsp;
|
||||
final uri = Uri.parse(rtsp);
|
||||
if (uri.userInfo.isNotEmpty) return rtsp; // 已带凭证
|
||||
final cred = '$username:$password'; // 始终带冒号:空密码→'admin:'
|
||||
return uri.replace(userInfo: cred).toString();
|
||||
}
|
||||
}
|
||||
|
||||
class OnvifException implements Exception {
|
||||
final String message;
|
||||
OnvifException(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
231
lib/features/v2/local_camera/data/onvif_discovery_service.dart
Normal file
231
lib/features/v2/local_camera/data/onvif_discovery_service.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../domain/entities/onvif_camera.dart';
|
||||
import 'lan_interface_selector.dart';
|
||||
|
||||
/// 原生实现 ONVIF WS-Discovery。
|
||||
///
|
||||
/// 关键点:向组播 239.255.255.250:3702 发 Probe,并且**从本机每一块网卡各发一份**
|
||||
/// (外加向该网段的定向广播 x.x.x.255 各发一份)。这样无论默认路由落在 WiFi/移动
|
||||
/// 还是以太网,总有一份 Probe 会从摄像头真正所在的那块网卡出去,摄像头收到后
|
||||
/// 会用 ProbeMatch 把它的真实 device_service 地址(含正确端口/路径)回给我们,
|
||||
/// 从根上避免"退化成盲扫猜端口"。
|
||||
///
|
||||
/// 不依赖第三方 onvif 包,便于完全掌控"搜索中 / 未发现"等提示链路。
|
||||
class OnvifDiscoveryService {
|
||||
static const String _multicastAddr = '239.255.255.250';
|
||||
static const int _multicastPort = 3702;
|
||||
|
||||
final Random _rnd = Random.secure();
|
||||
|
||||
/// 搜索局域网摄像头。[timeout] 为收集窗口时长。
|
||||
/// 返回去重后的设备列表(ip + xAddr)。搜索失败/超时返回空列表,不抛异常。
|
||||
Future<List<OnvifCamera>> discover({
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final found = <String, OnvifCamera>{};
|
||||
final completer = Completer<List<OnvifCamera>>();
|
||||
final sockets = <_ProbeSocket>[];
|
||||
int rxCount = 0;
|
||||
|
||||
debugPrint('🔎 [ONVIF发现] 开始搜索 $_multicastAddr:$_multicastPort');
|
||||
|
||||
// 只挑“很可能是以太网”的网卡(本功能摄像头固定走 USB 以太网,
|
||||
// 排除 WiFi/移动网卡,避免向无关网段发 Probe / 扫错网段)。
|
||||
final lans = await selectLanInterfaces();
|
||||
|
||||
if (lans.isEmpty) {
|
||||
debugPrint('🔎 [ONVIF发现] ⚠️ 未识别到可用网卡(以太网可能未接入)');
|
||||
}
|
||||
for (final l in lans) {
|
||||
debugPrint('🔎 [ONVIF发现] 探测网卡 ${l.name} -> ${l.ip}');
|
||||
}
|
||||
|
||||
final multicast = InternetAddress(_multicastAddr);
|
||||
|
||||
// 在指定本地网卡上开一个套接字:先试 3702(能收到以组播方式回的 ProbeMatch),
|
||||
// 失败再回退临时端口。
|
||||
Future<RawDatagramSocket?> openSocket(InternetAddress local) async {
|
||||
for (final port in <int>[_multicastPort, 0]) {
|
||||
try {
|
||||
final s = await RawDatagramSocket.bind(local, port);
|
||||
s.broadcastEnabled = true;
|
||||
s.multicastLoopback = true;
|
||||
try {
|
||||
s.joinMulticast(multicast);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'🔎 [ONVIF发现] ${local.address} joinMulticast 失败: ${e.runtimeType}',
|
||||
);
|
||||
}
|
||||
return s;
|
||||
} catch (_) {
|
||||
// 该端口不可用,尝试下一个
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 为每块网卡的每个 IPv4 各开一个 socket,并挂上共享监听
|
||||
Future<void> attach(InternetAddress local, String ipStr) async {
|
||||
final s = await openSocket(local);
|
||||
if (s == null) {
|
||||
debugPrint('🔎 [ONVIF发现] ⚠️ $ipStr 上无法建立套接字');
|
||||
return;
|
||||
}
|
||||
// 计算该 /24 的定向广播地址(x.x.x.255)
|
||||
String? broadcast;
|
||||
final seg = ipStr.split('.');
|
||||
if (seg.length == 4) broadcast = '${seg[0]}.${seg[1]}.${seg[2]}.255';
|
||||
sockets.add(_ProbeSocket(s, ipStr, broadcast));
|
||||
|
||||
s.listen((event) {
|
||||
if (event != RawSocketEvent.read) return;
|
||||
final dg = s.receive();
|
||||
if (dg == null) return;
|
||||
rxCount++;
|
||||
try {
|
||||
final xmlStr = utf8.decode(dg.data, allowMalformed: true);
|
||||
final cam = _parseProbeMatch(xmlStr, dg.address.address);
|
||||
debugPrint(
|
||||
'🔎 [ONVIF发现] 收到回应 from ${dg.address.address} · ${dg.data.length}字节 → ${cam?.displayName ?? "未匹配到XAddrs"}',
|
||||
);
|
||||
if (cam != null) found[cam.ip] = cam;
|
||||
} catch (e) {
|
||||
debugPrint('🔎 [ONVIF发现] 报文解析失败: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (lans.isEmpty) {
|
||||
await attach(InternetAddress.anyIPv4, InternetAddress.anyIPv4.address);
|
||||
} else {
|
||||
for (final l in lans) {
|
||||
await attach(l.address, l.ip);
|
||||
}
|
||||
}
|
||||
|
||||
if (sockets.isEmpty) {
|
||||
debugPrint('🔎 [ONVIF发现] ❌ 没有任何可用网卡套接字');
|
||||
return const [];
|
||||
}
|
||||
|
||||
final probe = _buildProbeMessage();
|
||||
final probeBytes = utf8.encode(probe);
|
||||
|
||||
void sendProbe() {
|
||||
for (final ps in sockets) {
|
||||
try {
|
||||
final sent = ps.socket.send(probeBytes, multicast, _multicastPort);
|
||||
debugPrint('🔎 [ONVIF发现] 经 ${ps.ip} 发送组播 Probe 字节数=$sent');
|
||||
} catch (e) {
|
||||
debugPrint('🔎 [ONVIF发现] ${ps.ip} 发送组播 Probe 失败: $e');
|
||||
}
|
||||
// 额外向该网段定向广播一份,照顾不支持组播路由的环境
|
||||
final bc = ps.broadcast;
|
||||
if (bc != null) {
|
||||
try {
|
||||
ps.socket.send(probeBytes, InternetAddress(bc), _multicastPort);
|
||||
debugPrint('🔎 [ONVIF发现] 经 ${ps.ip} 定向广播 Probe → $bc');
|
||||
} catch (e) {
|
||||
debugPrint('🔎 [ONVIF发现] ${ps.ip} 定向广播失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendProbe();
|
||||
Timer(const Duration(milliseconds: 1000), sendProbe);
|
||||
Timer(const Duration(milliseconds: 2000), sendProbe);
|
||||
|
||||
Timer(timeout, () {
|
||||
for (final ps in sockets) {
|
||||
ps.socket.close();
|
||||
}
|
||||
debugPrint(
|
||||
'🔎 [ONVIF发现] 搜索结束:收到回应 $rxCount 条,发现摄像头 ${found.length} 个',
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(found.values.toList());
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
String _uuid() {
|
||||
final bytes = List<int>.generate(16, (_) => _rnd.nextInt(256));
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return 'urn:uuid:${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
|
||||
}
|
||||
|
||||
String _buildProbeMessage() {
|
||||
return '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||
xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"
|
||||
xmlns:dn="http://www.onvif.org/ver10/network/wsdl">
|
||||
<s:Header>
|
||||
<a:Action s:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
|
||||
<a:MessageID>${_uuid()}</a:MessageID>
|
||||
<a:To s:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
|
||||
<d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe>
|
||||
</s:Header>
|
||||
<s:Body/>
|
||||
</s:Envelope>''';
|
||||
}
|
||||
|
||||
OnvifCamera? _parseProbeMatch(String xmlStr, String senderIp) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(xmlStr);
|
||||
final xAddrNodes =
|
||||
doc
|
||||
.findAllElements('XAddrs')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
if (xAddrNodes.isEmpty) return null;
|
||||
|
||||
// XAddrs 可能是空格分隔的多地址,优先取 http
|
||||
final allAddrs = xAddrNodes
|
||||
.expand((s) => s.split(RegExp(r'\s+')))
|
||||
.where((s) => s.startsWith('http'))
|
||||
.toList();
|
||||
final xAddr = allAddrs.isNotEmpty ? allAddrs.first : xAddrNodes.first;
|
||||
|
||||
final uri = Uri.parse(xAddr);
|
||||
final ip = uri.host.isNotEmpty ? uri.host : senderIp;
|
||||
final port = uri.hasPort ? uri.port : 80;
|
||||
|
||||
// 从 Scopes 里尽量提取型号(很多摄像头把 hw= / name= 放进 scope)
|
||||
String model = '';
|
||||
final scopes = doc
|
||||
.findAllElements('Scopes')
|
||||
.map((e) => e.innerText)
|
||||
.join(' ');
|
||||
final hwMatch = RegExp(r'hw=([^@\s]+)').firstMatch(scopes);
|
||||
if (hwMatch != null) {
|
||||
model = Uri.decodeComponent(hwMatch.group(1)!);
|
||||
}
|
||||
|
||||
return OnvifCamera(ip: ip, port: port, xAddr: xAddr, model: model);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一块网卡对应的探测套接字 + 该网卡身份/定向广播地址。
|
||||
class _ProbeSocket {
|
||||
final RawDatagramSocket socket;
|
||||
final String ip;
|
||||
final String? broadcast;
|
||||
_ProbeSocket(this.socket, this.ip, this.broadcast);
|
||||
}
|
||||
224
lib/features/v2/local_camera/data/onvif_subnet_scanner.dart
Normal file
224
lib/features/v2/local_camera/data/onvif_subnet_scanner.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../domain/entities/onvif_camera.dart';
|
||||
import 'onvif_client.dart';
|
||||
|
||||
/// 网段扫描兜底发现(对标 IP Cam Viewer 的 "Network IP Scanner")。
|
||||
///
|
||||
/// 不依赖 WS-Discovery 组播,避免 Android 组播过滤 / USB 以太网组播不确定性:
|
||||
/// 取本机所在 /24,逐个 IP 直连常见 ONVIF HTTP 端口,命中后用
|
||||
/// [OnvifClient.getDeviceInformation] 有 SOAP 回包即视为 ONVIF 设备并列出。
|
||||
///
|
||||
/// **不做过严校验**:很多"没正规名字"的杂牌摄像头 DeviceInformation 会返回
|
||||
/// 空 manufacturer/model、或 GetCapabilities(Media) 未实现,但**它就是摄像头本物**——
|
||||
/// 只要 SOAP 有回包就列入结果;播放环节由 cubit 通过 RTSP 猜测表兜底。
|
||||
class OnvifSubnetScanner {
|
||||
/// 常见 ONVIF device_service 端口(海康 80/8000/8080,大华 80/880,杂牌 8899/81 等)。
|
||||
static const List<int> _candidatePorts = [
|
||||
80,
|
||||
8000,
|
||||
8080,
|
||||
880,
|
||||
8081,
|
||||
8899,
|
||||
81,
|
||||
808,
|
||||
];
|
||||
|
||||
/// 常见 device_service 路径。
|
||||
static const List<String> _servicePaths = [
|
||||
'/onvif/device_service',
|
||||
'/onvif',
|
||||
'/device_service',
|
||||
];
|
||||
|
||||
final OnvifClient client;
|
||||
|
||||
/// 单端口 TCP 连通超时(扫描要快,设短)。
|
||||
final Duration connectTimeout;
|
||||
|
||||
/// 一次 ONVIF 校验的超时。
|
||||
final Duration probeTimeout;
|
||||
|
||||
/// 并发扫描的主机数。
|
||||
final int maxConcurrent;
|
||||
|
||||
OnvifSubnetScanner({
|
||||
required this.client,
|
||||
this.connectTimeout = const Duration(milliseconds: 350),
|
||||
this.probeTimeout = const Duration(seconds: 3),
|
||||
this.maxConcurrent = 40,
|
||||
});
|
||||
|
||||
/// 扫描本机所在的所有 /24 网段,返回 ONVIF 摄像头候选。
|
||||
Future<List<OnvifCamera>> scan() async {
|
||||
final localIps = <String>{};
|
||||
final prefixes = <String>{};
|
||||
|
||||
try {
|
||||
final ifs = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
// 摄像头固定走 USB 以太网 → 只扫以太网网卡所在网段(更快、不误捞 WiFi/移动)。
|
||||
// 认不出以太网命名时,退成“排除 WiFi/移动后的网卡”;仍空则全部(保守兜底)。
|
||||
var pick = ifs.where((i) => _looksEthernet(i.name)).toList();
|
||||
if (pick.isEmpty) {
|
||||
pick = ifs.where((i) => !_looksWifiOrCellular(i.name)).toList();
|
||||
}
|
||||
if (pick.isEmpty) pick = ifs;
|
||||
// localIps 收集全部本机 IP(避免扫到自己),prefixes 只取选中网卡所在网段
|
||||
for (final i in ifs) {
|
||||
for (final a in i.addresses) {
|
||||
localIps.add(a.address.toString());
|
||||
}
|
||||
}
|
||||
for (final i in pick) {
|
||||
debugPrint('🛰 [网段扫描] 采用网卡 ${i.name}');
|
||||
for (final a in i.addresses) {
|
||||
final parts = a.address.toString().split('.');
|
||||
if (parts.length == 4) {
|
||||
prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('🛰 [网段扫描] ❌ 枚举本机网卡失败: $e');
|
||||
return const [];
|
||||
}
|
||||
|
||||
if (prefixes.isEmpty) {
|
||||
debugPrint('🛰 [网段扫描] ⚠️ 未识别到本机 IPv4 网段,跳过扫描');
|
||||
return const [];
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'🛰 [网段扫描] 本机 IP=${localIps.join(", ")},将扫描网段 ${prefixes.join(", ")}.*',
|
||||
);
|
||||
|
||||
// 生成候选主机(排除本机自身)
|
||||
final hosts = <String>[];
|
||||
for (final p in prefixes) {
|
||||
for (var h = 1; h <= 254; h++) {
|
||||
final ip = '$p.$h';
|
||||
if (!localIps.contains(ip)) hosts.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
final results = <String, OnvifCamera>{};
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
await _runPool(hosts, maxConcurrent, (ip) async {
|
||||
final cam = await _probeHost(ip);
|
||||
if (cam != null) {
|
||||
results[cam.ip] = cam;
|
||||
debugPrint('🛰 [网段扫描] ✅ 命中 $ip → ${cam.displayName}(${cam.xAddr})');
|
||||
}
|
||||
});
|
||||
|
||||
stopwatch.stop();
|
||||
debugPrint(
|
||||
'🛰 [网段扫描] 结束:扫描 ${hosts.length} 个 IP,用时 ${stopwatch.elapsedMilliseconds}ms,发现 ${results.length} 个 ONVIF 设备',
|
||||
);
|
||||
return results.values.toList();
|
||||
}
|
||||
|
||||
/// 对单个 IP:并发探所有候选端口;只要任一 (port,path) 的
|
||||
/// `GetDeviceInformation` 有 SOAP 回包,立即返回该端点作为候选。
|
||||
/// **不再要求 Media 校验通过**,杂牌/精简 ONVIF 也能列出。
|
||||
Future<OnvifCamera?> _probeHost(String ip) async {
|
||||
final results = await Future.wait(
|
||||
_candidatePorts.map(
|
||||
(p) => _isTcpOpen(ip, p).then((ok) => MapEntry(p, ok)),
|
||||
),
|
||||
);
|
||||
// 按 _candidatePorts 优先顺序保留开放端口
|
||||
final openPorts = [for (final e in results) if (e.value) e.key];
|
||||
|
||||
for (final port in openPorts) {
|
||||
for (final path in _servicePaths) {
|
||||
final xAddr = 'http://$ip:$port$path';
|
||||
try {
|
||||
final info = await client
|
||||
.getDeviceInformation(xAddr)
|
||||
.timeout(probeTimeout);
|
||||
// 只要 SOAP 拿到 DeviceInformation(哪怕字段为空)就认作 ONVIF 设备。
|
||||
debugPrint(
|
||||
'🛰 [网段扫描] ✔ $ip:$port$path 应答 DeviceInformation(型号=${info.model.isEmpty ? info.manufacturer : info.model})',
|
||||
);
|
||||
return OnvifCamera(
|
||||
ip: ip,
|
||||
port: port,
|
||||
xAddr: xAddr,
|
||||
manufacturer: info.manufacturer,
|
||||
model: info.model,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('🛰 [网段扫描] ✖ $ip:$port$path: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _isTcpOpen(String ip, int port) async {
|
||||
Socket? s;
|
||||
try {
|
||||
s = await Socket.connect(ip, port, timeout: connectTimeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
s?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/// 简易有界并发:同时最多 [max] 个任务。
|
||||
Future<void> _runPool<T>(
|
||||
List<T> items,
|
||||
int max,
|
||||
Future<void> Function(T) worker,
|
||||
) async {
|
||||
var index = 0;
|
||||
Future<void> runner() async {
|
||||
while (true) {
|
||||
final i = index++;
|
||||
if (i >= items.length) return;
|
||||
await worker(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final workers = <Future<void>>[];
|
||||
for (var w = 0; w < max && w < items.length; w++) {
|
||||
workers.add(runner());
|
||||
}
|
||||
await Future.wait(workers);
|
||||
}
|
||||
|
||||
static bool _looksEthernet(String raw) {
|
||||
final n = raw.toLowerCase();
|
||||
return n.startsWith('eth') ||
|
||||
n.startsWith('en') ||
|
||||
n.startsWith('usb') ||
|
||||
n.contains('rndis') ||
|
||||
n.contains('asix') ||
|
||||
n.contains('ax88') ||
|
||||
n.contains('mtk_eth');
|
||||
}
|
||||
|
||||
static bool _looksWifiOrCellular(String raw) {
|
||||
final n = raw.toLowerCase();
|
||||
const blocked = [
|
||||
'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile',
|
||||
'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan',
|
||||
'bt', 'swlan', 'utun', 'ipsec',
|
||||
];
|
||||
for (final p in blocked) {
|
||||
if (n.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
59
lib/features/v2/local_camera/data/rtsp_url_guesser.dart
Normal file
59
lib/features/v2/local_camera/data/rtsp_url_guesser.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
/// 常见摄像头 RTSP 播放地址模板库(对标 IP Cam Viewer 的 brand templates)。
|
||||
///
|
||||
/// 大量"没有正规名字"的杂牌摄像头其实都基于同一批芯片方案
|
||||
/// (雄迈/XMEye、海康、大华、全志、君正、Reolink、Amcrest、Uniview 等),
|
||||
/// ONVIF 元信息经常为空,但 RTSP 路径是固定套路。
|
||||
/// 只要拿到 IP,按这份顺序挨个试,大概率命中。
|
||||
///
|
||||
/// 说明:
|
||||
/// - 空密码使用 `admin:@` 形式(libmpv/ffmpeg 均支持),比 `admin@` 更规范;
|
||||
/// - 端口默认 554(RTSP 标准);个别厂商用 555/10554/8554 也可扩。
|
||||
class RtspUrlGuesser {
|
||||
/// 返回按"最可能命中 → 最不可能"排序的候选 URL。
|
||||
///
|
||||
/// [user] / [password] 为空时会自动跳过带鉴权的前缀。
|
||||
static List<String> candidates({
|
||||
required String ip,
|
||||
required String user,
|
||||
required String password,
|
||||
int rtspPort = 554,
|
||||
}) {
|
||||
final auth = user.isEmpty
|
||||
? ''
|
||||
: '$user:$password@'; // 空密码 → 'admin:@',兼容大多摄像头
|
||||
final base = 'rtsp://$auth$ip:$rtspPort';
|
||||
return <String>[
|
||||
// === 雄迈/XMEye/全志/君正 通用(市面绝大多数"没名字"的杂牌摄像头都是这批芯片) ===
|
||||
'$base/onvif1',
|
||||
'$base/11',
|
||||
'$base/12', // 子码流
|
||||
'$base/md0', // 单路 md0
|
||||
'$base/md1',
|
||||
'$base/0_0', // XM 双路编码
|
||||
'$base/unicast',
|
||||
|
||||
// === 海康 Hikvision(部分老机型即使 ONVIF 不实现也能这样直连) ===
|
||||
'$base/Streaming/Channels/101',
|
||||
'$base/Streaming/Channels/1',
|
||||
'$base/h264/ch1/main/av_stream',
|
||||
'$base/MPEG4/ch1/main/av_stream',
|
||||
|
||||
// === 大华 Dahua ===
|
||||
'$base/cam/realmonitor?channel=1&subtype=0',
|
||||
|
||||
// === 宇视 Uniview ===
|
||||
'$base/media/video1',
|
||||
'$base/unicast/c1/s1/live',
|
||||
|
||||
// === Reolink / Amcrest / Tendasee / 通用 ===
|
||||
'$base/stream1',
|
||||
'$base/video1',
|
||||
'$base/live/ch00_0',
|
||||
'$base/live',
|
||||
'$base/1',
|
||||
|
||||
// === 极简兜底:根路径 ===
|
||||
base,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 局域网内通过 ONVIF WS-Discovery 发现的摄像头信息。
|
||||
class OnvifCamera {
|
||||
final String ip;
|
||||
final int port;
|
||||
final String xAddr; // ONVIF device_service 地址
|
||||
final String manufacturer;
|
||||
final String model;
|
||||
|
||||
const OnvifCamera({
|
||||
required this.ip,
|
||||
required this.port,
|
||||
required this.xAddr,
|
||||
this.manufacturer = '',
|
||||
this.model = '',
|
||||
});
|
||||
|
||||
/// 列表展示:IP · 型号
|
||||
String get displayName {
|
||||
final m = model.isNotEmpty
|
||||
? model
|
||||
: (manufacturer.isNotEmpty ? manufacturer : '未知型号');
|
||||
return '$ip · $m';
|
||||
}
|
||||
|
||||
OnvifCamera copyWith({String? manufacturer, String? model}) => OnvifCamera(
|
||||
ip: ip,
|
||||
port: port,
|
||||
xAddr: xAddr,
|
||||
manufacturer: manufacturer ?? this.manufacturer,
|
||||
model: model ?? this.model,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'OnvifCamera($ip:$port, model=$model, xAddr=$xAddr)';
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../data/onvif_client.dart';
|
||||
import '../../data/onvif_discovery_service.dart';
|
||||
import '../../data/onvif_subnet_scanner.dart';
|
||||
import '../../data/rtsp_url_guesser.dart';
|
||||
import '../../domain/entities/onvif_camera.dart';
|
||||
import 'local_camera_state.dart';
|
||||
|
||||
/// 本地摄像头(ONVIF 发现 + RTSP 播放)核心 Cubit。
|
||||
/// 负责:网络检查 → WS-Discovery 搜索 → ONVIF 取 RTSP → video_player(ExoPlayer) 拉流播放 → 断流/失败处理。
|
||||
class LocalCameraCubit extends Cubit<LocalCameraState> {
|
||||
LocalCameraCubit({OnvifClient? client})
|
||||
: _client = client ?? OnvifClient(username: 'admin', password: ''),
|
||||
super(const LocalCameraState());
|
||||
|
||||
final OnvifDiscoveryService _discovery = OnvifDiscoveryService();
|
||||
final OnvifClient _client;
|
||||
|
||||
/// 网段扫描兜底(组播失败时不依赖组播,直接 TCP 探设 ONVIF 设备)。
|
||||
late final OnvifSubnetScanner _scanner = OnvifSubnetScanner(client: _client);
|
||||
|
||||
// ===== 播放器(video_player / Android=ExoPlayer)=====
|
||||
// ExoPlayer 原生支持 RTSP(RtspMediaSource),默认走 TCP 交错、缓冲小,天然低延迟,
|
||||
// 不需要 mpv 那套 cache/demuxer 手动调参。
|
||||
VideoPlayerController? _videoController;
|
||||
VoidCallback? _controllerListener;
|
||||
|
||||
/// 供页面渲染视频用;未就绪时为 null。
|
||||
VideoPlayerController? get videoController => _videoController;
|
||||
|
||||
static const String _prefsKeyLastList = 'local_camera_last_list';
|
||||
|
||||
// ===== 提示语常量(严格按需求原文)=====
|
||||
static const String msgNoNetworkEntry = '请打开手机以太网网络共享和连上设备的以太网线,才能搜索摄像头';
|
||||
static const String msgNoNetwork = '请打开手机以太网网络共享(或连上网络),才能搜索摄像头';
|
||||
static const String msgSearching = '正在搜索局域网内摄像头,请稍候...';
|
||||
static const String msgNotFound =
|
||||
'未发现摄像头。请检查:①网络已连接 ②摄像头开启ONVIF ③同一网段';
|
||||
static const String msgDisconnected = '共享网络断开,请去检查';
|
||||
|
||||
StreamSubscription? _connectivitySub;
|
||||
Timer? _openWatchdog;
|
||||
Timer? _stallTimer;
|
||||
bool _userStopped = false;
|
||||
|
||||
/// 页面进入时调用:检查网络并自动搜索。
|
||||
Future<void> start() async {
|
||||
_listenConnectivity();
|
||||
await _checkNetworkAndDiscover();
|
||||
}
|
||||
|
||||
/// 链条:①网络检查 ②ONVIF发现 ③取流(GetStreamUri) ④拉流播放
|
||||
Future<void> _checkNetworkAndDiscover() async {
|
||||
emit(state.copyWith(networkStep: ChainStatus.running));
|
||||
final ok = await _isEthernetConnected();
|
||||
if (!ok) {
|
||||
final detail = await _describeNetworks();
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.noNetwork,
|
||||
networkStep: ChainStatus.failed,
|
||||
discoveryStep: ChainStatus.idle,
|
||||
message: '$msgNoNetworkEntry\n\n当前网卡:$detail',
|
||||
clearSelected: true,
|
||||
clearRtsp: true,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(networkStep: ChainStatus.success));
|
||||
await runDiscovery();
|
||||
}
|
||||
|
||||
/// 链条②:WS-Discovery 搜索摄像头。
|
||||
Future<void> runDiscovery() async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.searching,
|
||||
discoveryStep: ChainStatus.running,
|
||||
streamStep: ChainStatus.idle,
|
||||
playStep: ChainStatus.idle,
|
||||
clearSelected: true,
|
||||
clearRtsp: true,
|
||||
message: msgSearching,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
var cams = await _discovery.discover();
|
||||
// 组播对杂牌摄像头通常无应答 → 先快速校验上次缓存的 IP,命中即秒开,
|
||||
// 避免每次都跑满 254×8 的网段扫描("有时扫不到 + 耗时长"的主因)。
|
||||
if (cams.isEmpty) {
|
||||
cams = await _validateCached(await loadCachedCameras());
|
||||
}
|
||||
if (cams.isEmpty) {
|
||||
// 组播未发现 → 网段扫描兜底(对齐 IP Cam Viewer 的 IP Scanner,不吃组播/锁)
|
||||
debugPrint('🔎 [发现] 组播未发现,尝试网段扫描兜底');
|
||||
cams = await _scanner.scan();
|
||||
}
|
||||
if (cams.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.notFound,
|
||||
discoveryStep: ChainStatus.failed,
|
||||
cameras: const [],
|
||||
message: msgNotFound,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尽量补全型号(best-effort,失败不影响列表展示)
|
||||
final enriched = await Future.wait(
|
||||
cams.map((c) async {
|
||||
if (c.model.isNotEmpty) return c;
|
||||
try {
|
||||
final info = await _client.getDeviceInformation(c.xAddr);
|
||||
return c.copyWith(manufacturer: info.manufacturer, model: info.model);
|
||||
} catch (_) {
|
||||
return c;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await _cacheCameras(enriched);
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.found,
|
||||
discoveryStep: ChainStatus.success,
|
||||
cameras: enriched,
|
||||
message: null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.notFound,
|
||||
discoveryStep: ChainStatus.failed,
|
||||
message: msgNotFound,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 链条③④:选择摄像头 → 依次试候选 RTSP URL。
|
||||
/// 候选顺序:① ONVIF 正规 GetStreamUri 结果 → ② RtspUrlGuesser 常见品牌模板。
|
||||
Future<void> selectCamera(OnvifCamera cam) async {
|
||||
_userStopped = false;
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.connecting,
|
||||
selected: cam,
|
||||
streamStep: ChainStatus.running,
|
||||
playStep: ChainStatus.idle,
|
||||
clearRtsp: true,
|
||||
clearMessage: true,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
|
||||
await _disposeController();
|
||||
_armOpenWatchdog();
|
||||
|
||||
// 1) 先按 ONVIF 正道取流(能拿到就放到候选第一位)
|
||||
final urls = <String>[];
|
||||
String? onvifUrl;
|
||||
try {
|
||||
onvifUrl = await _client.resolveRtsp(cam.xAddr);
|
||||
urls.add(onvifUrl);
|
||||
debugPrint('🎬 [拉流] ONVIF 解析成功:$onvifUrl');
|
||||
} catch (e) {
|
||||
debugPrint('🎬 [拉流] ONVIF 未给出 RTSP:$e');
|
||||
}
|
||||
|
||||
// 2) 猜测表兜底(杂牌摄像头大概率命中这里)
|
||||
urls.addAll(
|
||||
RtspUrlGuesser.candidates(
|
||||
ip: cam.ip,
|
||||
user: _client.username,
|
||||
password: _client.password,
|
||||
),
|
||||
);
|
||||
final dedup = urls.toSet().toList();
|
||||
if (dedup.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.playFailed,
|
||||
streamStep: ChainStatus.failed,
|
||||
playStep: ChainStatus.failed,
|
||||
message: '无可用 RTSP 地址',
|
||||
showReconnect: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 逐个试:任一 URL 在 5s 内 playing=true 即成功
|
||||
for (var i = 0; i < dedup.length; i++) {
|
||||
final url = dedup[i];
|
||||
debugPrint('🎬 [拉流] 尝试 #${i + 1}/${dedup.length}: $url');
|
||||
final ok = await _tryPlay(url, timeout: const Duration(seconds: 5));
|
||||
if (ok) {
|
||||
_cancelWatchdog();
|
||||
debugPrint('🎬 [拉流] 成功:$url');
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.playing,
|
||||
rtspUrl: url,
|
||||
streamStep: ChainStatus.success,
|
||||
playStep: ChainStatus.success,
|
||||
clearMessage: true,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部失败
|
||||
_cancelWatchdog();
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.playFailed,
|
||||
streamStep: onvifUrl != null
|
||||
? ChainStatus.success
|
||||
: ChainStatus.failed,
|
||||
playStep: ChainStatus.failed,
|
||||
message: '无法拉流:已尝试 ONVIF 与 ${dedup.length} 个常见 RTSP 路径',
|
||||
showReconnect: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开单个候选 URL,[timeout] 内完成 initialize() 且首帧就绪即视为成功。
|
||||
/// ExoPlayer 对 RTSP 直播默认走 TCP 交错、缓冲小;失败/超时则 dispose 后返回 false。
|
||||
Future<bool> _tryPlay(String url, {required Duration timeout}) async {
|
||||
VideoPlayerController? c;
|
||||
try {
|
||||
c = VideoPlayerController.networkUrl(Uri.parse(url));
|
||||
await c.initialize().timeout(timeout);
|
||||
// 直播流 initialize 完成即已连上并拿到分辨率;立即起播
|
||||
await c.play();
|
||||
// 短暂观察是否立刻报错(错误路径 / 鉴权失败会在这里暴露)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (c.value.hasError) {
|
||||
debugPrint(
|
||||
'🎬 [拉流] 起播即报错:${c.value.errorDescription}',
|
||||
);
|
||||
await c.dispose();
|
||||
return false;
|
||||
}
|
||||
_videoController = c;
|
||||
_attachControllerListener(c);
|
||||
return true;
|
||||
} on TimeoutException {
|
||||
debugPrint('🎬 [拉流] 超时:$url');
|
||||
await c?.dispose();
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('🎬 [拉流] open 抛错:$e');
|
||||
await c?.dispose();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听正在播放的控制器:断流/报错/长时间缓冲 → 判定断开。
|
||||
void _attachControllerListener(VideoPlayerController c) {
|
||||
void listener() {
|
||||
if (_userStopped || isClosed) return;
|
||||
if (c.value.hasError) {
|
||||
if (state.phase == LocalCameraPhase.playing) _handleDisconnected();
|
||||
return;
|
||||
}
|
||||
if (state.phase == LocalCameraPhase.playing) {
|
||||
if (c.value.isBuffering) {
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = Timer(const Duration(seconds: 12), _handleDisconnected);
|
||||
} else {
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_controllerListener = listener;
|
||||
c.addListener(listener);
|
||||
}
|
||||
|
||||
/// 释放当前控制器并摘掉监听。
|
||||
Future<void> _disposeController() async {
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
final c = _videoController;
|
||||
_videoController = null;
|
||||
if (c != null) {
|
||||
if (_controllerListener != null) c.removeListener(_controllerListener!);
|
||||
_controllerListener = null;
|
||||
try {
|
||||
await c.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelWatchdog() {
|
||||
_openWatchdog?.cancel();
|
||||
_openWatchdog = null;
|
||||
}
|
||||
|
||||
void _handleDisconnected() {
|
||||
if (_userStopped || isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.disconnected,
|
||||
playStep: ChainStatus.failed,
|
||||
message: msgDisconnected,
|
||||
showReconnect: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击"重新连接":有选中摄像头则重拉流,否则重新搜索。
|
||||
Future<void> reconnect() async {
|
||||
final cam = state.selected;
|
||||
if (cam == null) {
|
||||
await _checkNetworkAndDiscover();
|
||||
return;
|
||||
}
|
||||
await selectCamera(cam);
|
||||
}
|
||||
|
||||
/// 返回摄像头列表(重新搜索)。
|
||||
Future<void> backToList() async {
|
||||
_userStopped = true;
|
||||
_cancelWatchdog();
|
||||
_stallTimer?.cancel();
|
||||
await _disposeController();
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: state.cameras.isNotEmpty
|
||||
? LocalCameraPhase.found
|
||||
: LocalCameraPhase.idle,
|
||||
clearSelected: true,
|
||||
clearRtsp: true,
|
||||
streamStep: ChainStatus.idle,
|
||||
playStep: ChainStatus.idle,
|
||||
clearMessage: true,
|
||||
showReconnect: false,
|
||||
),
|
||||
);
|
||||
if (state.cameras.isEmpty) {
|
||||
await runDiscovery();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 共享网络(以太网)检查 =====
|
||||
/// 本功能运行环境:手机需开启“以太网共享”并接上 USB‑C 转以太网,
|
||||
/// 才能跟局域网摄像头通信。这里用"双信号取或"保证不漏检:
|
||||
/// 1) `connectivity_plus` 直接报 `ConnectivityResult.ethernet`;
|
||||
/// 2) 本机网卡里存在“名字像以太网 或 非 WiFi/移动”的 IPv4 接口。
|
||||
Future<bool> _isEthernetConnected() async {
|
||||
// 信号 1
|
||||
try {
|
||||
final results = await Connectivity().checkConnectivity();
|
||||
if (results.contains(ConnectivityResult.ethernet)) {
|
||||
debugPrint('🌐 [网络检查] connectivity_plus 报以太网 ✔');
|
||||
return true;
|
||||
}
|
||||
debugPrint('🌐 [网络检查] connectivity_plus 结果:$results');
|
||||
} catch (e) {
|
||||
debugPrint('🌐 [网络检查] connectivity_plus 失败:$e');
|
||||
}
|
||||
// 信号 2
|
||||
try {
|
||||
final ifs = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
for (final i in ifs) {
|
||||
if (_looksEthernet(i.name)) {
|
||||
debugPrint('🌐 [网络检查] 网卡 ${i.name} 明确为以太网 ✔');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (final i in ifs) {
|
||||
if (!_looksWifiOrCellular(i.name) && i.addresses.isNotEmpty) {
|
||||
debugPrint('🌐 [网络检查] 网卡 ${i.name} 非 WiFi/移动,视为以太网共享 ✔');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
debugPrint('🌐 [网络检查] 未发现以太网:${_fmtIfs(ifs)}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('🌐 [网络检查] 枚举网卡失败:$e(保守放行,交给后续探测)');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 把当前网卡列成人读摘要(失败提示拼上,用户一眼看到“到底有没有插”)。
|
||||
Future<String> _describeNetworks() async {
|
||||
try {
|
||||
final ifs = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
if (ifs.isEmpty) return '无任何非回环 IPv4 网卡(以太网线可能未接好或共享未开)';
|
||||
return _fmtIfs(ifs);
|
||||
} catch (e) {
|
||||
return '网卡枚举失败:$e';
|
||||
}
|
||||
}
|
||||
|
||||
static bool _looksEthernet(String raw) {
|
||||
final n = raw.toLowerCase();
|
||||
return n.startsWith('eth') ||
|
||||
n.startsWith('en') ||
|
||||
n.startsWith('usb') ||
|
||||
n.contains('rndis') ||
|
||||
n.contains('asix') ||
|
||||
n.contains('ax88') ||
|
||||
n.contains('mtk_eth');
|
||||
}
|
||||
|
||||
static bool _looksWifiOrCellular(String raw) {
|
||||
final n = raw.toLowerCase();
|
||||
const prefixes = [
|
||||
'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile',
|
||||
'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan',
|
||||
'bt', 'swlan', 'utun', 'ipsec',
|
||||
];
|
||||
for (final p in prefixes) {
|
||||
if (n.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String _fmtIfs(List<NetworkInterface> ifs) {
|
||||
if (ifs.isEmpty) return '无任何非回环 IPv4 网卡';
|
||||
return ifs
|
||||
.map(
|
||||
(i) =>
|
||||
'${i.name}(${i.addresses.map((a) => a.address.toString()).join(",")})',
|
||||
)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
void _listenConnectivity() {
|
||||
_connectivitySub?.cancel();
|
||||
_connectivitySub = Connectivity().onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) {
|
||||
// 盯以太网掉线(拔掉以太网线 / 共享网络断开),而非任意网络
|
||||
final ethernetLost = !results.contains(ConnectivityResult.ethernet);
|
||||
if (ethernetLost &&
|
||||
(state.isPlaying || state.phase == LocalCameraPhase.connecting)) {
|
||||
_handleDisconnected();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 快速校验上次缓存的候选:短超时逐个探活,任一 SOAP 有回包即保留。
|
||||
/// 全部不可达则返回空,交由上层做满段扫描兜底。
|
||||
Future<List<OnvifCamera>> _validateCached(List<OnvifCamera> cached) async {
|
||||
if (cached.isEmpty) return const [];
|
||||
final results = await Future.wait(
|
||||
cached.map((c) async {
|
||||
try {
|
||||
await _client
|
||||
.getDeviceInformation(c.xAddr)
|
||||
.timeout(const Duration(milliseconds: 1200));
|
||||
debugPrint('缓存校验命中 ${c.xAddr},跳过满段扫描');
|
||||
return c;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results.whereType<OnvifCamera>().toList();
|
||||
}
|
||||
|
||||
void _armOpenWatchdog() {
|
||||
_openWatchdog?.cancel();
|
||||
_openWatchdog = Timer(const Duration(seconds: 15), () {
|
||||
// 15s 仍未成功出画面
|
||||
if (state.phase == LocalCameraPhase.connecting ||
|
||||
state.playStep == ChainStatus.running) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
phase: LocalCameraPhase.playFailed,
|
||||
playStep: ChainStatus.failed,
|
||||
message: '连接超时,拉取视频流失败',
|
||||
showReconnect: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 本地缓存(shared_preferences)=====
|
||||
Future<void> _cacheCameras(List<OnvifCamera> cams) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = cams
|
||||
.map(
|
||||
(c) => {
|
||||
'ip': c.ip,
|
||||
'port': c.port,
|
||||
'xAddr': c.xAddr,
|
||||
'manufacturer': c.manufacturer,
|
||||
'model': c.model,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await prefs.setString(_prefsKeyLastList, jsonEncode(list));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<List<OnvifCamera>> loadCachedCameras() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefsKeyLastList);
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
return list
|
||||
.map(
|
||||
(m) => OnvifCamera(
|
||||
ip: m['ip'] as String,
|
||||
port: m['port'] as int,
|
||||
xAddr: m['xAddr'] as String,
|
||||
manufacturer: (m['manufacturer'] ?? '') as String,
|
||||
model: (m['model'] ?? '') as String,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_userStopped = true;
|
||||
_openWatchdog?.cancel();
|
||||
_stallTimer?.cancel();
|
||||
_connectivitySub?.cancel();
|
||||
await _disposeController();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../domain/entities/onvif_camera.dart';
|
||||
|
||||
/// 链条每一步的状态
|
||||
enum ChainStatus { idle, running, success, failed }
|
||||
|
||||
/// 页面整体阶段
|
||||
enum LocalCameraPhase {
|
||||
idle, // 未开始
|
||||
searching, // 正在搜索
|
||||
found, // 已发现,等待选择
|
||||
notFound, // 未发现摄像头
|
||||
noNetwork, // 未联网
|
||||
connecting, // 正在取流/拉流
|
||||
playing, // 播放中
|
||||
disconnected, // 播放中断/断流
|
||||
playFailed, // 播放失败
|
||||
}
|
||||
|
||||
class LocalCameraState extends Equatable {
|
||||
final LocalCameraPhase phase;
|
||||
final ChainStatus networkStep;
|
||||
final ChainStatus discoveryStep;
|
||||
final ChainStatus streamStep; // ONVIF 取 RTSP 地址
|
||||
final ChainStatus playStep; // media_kit 拉流
|
||||
final List<OnvifCamera> cameras;
|
||||
final OnvifCamera? selected;
|
||||
final String? rtspUrl;
|
||||
final String? message; // 提示语
|
||||
final bool showReconnect; // 是否显示"重新连接"按钮
|
||||
|
||||
const LocalCameraState({
|
||||
this.phase = LocalCameraPhase.idle,
|
||||
this.networkStep = ChainStatus.idle,
|
||||
this.discoveryStep = ChainStatus.idle,
|
||||
this.streamStep = ChainStatus.idle,
|
||||
this.playStep = ChainStatus.idle,
|
||||
this.cameras = const [],
|
||||
this.selected,
|
||||
this.rtspUrl,
|
||||
this.message,
|
||||
this.showReconnect = false,
|
||||
});
|
||||
|
||||
bool get isPlaying => phase == LocalCameraPhase.playing;
|
||||
bool get showVideo =>
|
||||
rtspUrl != null &&
|
||||
(phase == LocalCameraPhase.connecting ||
|
||||
phase == LocalCameraPhase.playing ||
|
||||
phase == LocalCameraPhase.disconnected ||
|
||||
phase == LocalCameraPhase.playFailed);
|
||||
|
||||
LocalCameraState copyWith({
|
||||
LocalCameraPhase? phase,
|
||||
ChainStatus? networkStep,
|
||||
ChainStatus? discoveryStep,
|
||||
ChainStatus? streamStep,
|
||||
ChainStatus? playStep,
|
||||
List<OnvifCamera>? cameras,
|
||||
OnvifCamera? selected,
|
||||
bool clearSelected = false,
|
||||
String? rtspUrl,
|
||||
bool clearRtsp = false,
|
||||
String? message,
|
||||
bool clearMessage = false,
|
||||
bool? showReconnect,
|
||||
}) {
|
||||
return LocalCameraState(
|
||||
phase: phase ?? this.phase,
|
||||
networkStep: networkStep ?? this.networkStep,
|
||||
discoveryStep: discoveryStep ?? this.discoveryStep,
|
||||
streamStep: streamStep ?? this.streamStep,
|
||||
playStep: playStep ?? this.playStep,
|
||||
cameras: cameras ?? this.cameras,
|
||||
selected: clearSelected ? null : (selected ?? this.selected),
|
||||
rtspUrl: clearRtsp ? null : (rtspUrl ?? this.rtspUrl),
|
||||
message: clearMessage ? null : (message ?? this.message),
|
||||
showReconnect: showReconnect ?? this.showReconnect,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
phase,
|
||||
networkStep,
|
||||
discoveryStep,
|
||||
streamStep,
|
||||
playStep,
|
||||
cameras,
|
||||
selected,
|
||||
rtspUrl,
|
||||
message,
|
||||
showReconnect,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../domain/entities/onvif_camera.dart';
|
||||
import '../cubit/local_camera_cubit.dart';
|
||||
import '../cubit/local_camera_state.dart';
|
||||
|
||||
/// 本地视频页:ONVIF 自动发现 + RTSP 播放(搜索与播放合一)。
|
||||
/// 上方为播放器 / 搜索状态 / 摄像头列表,下方为链路状态"链条"实时展示。
|
||||
class LocalCameraPage extends StatelessWidget {
|
||||
const LocalCameraPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => LocalCameraCubit()..start(),
|
||||
child: const _LocalCameraView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalCameraView extends StatefulWidget {
|
||||
const _LocalCameraView();
|
||||
|
||||
@override
|
||||
State<_LocalCameraView> createState() => _LocalCameraViewState();
|
||||
}
|
||||
|
||||
class _LocalCameraViewState extends State<_LocalCameraView> {
|
||||
bool _noNetworkDialogShown = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<LocalCameraCubit>();
|
||||
|
||||
return BlocConsumer<LocalCameraCubit, LocalCameraState>(
|
||||
listenWhen: (prev, cur) => prev.phase != cur.phase,
|
||||
listener: (context, state) {
|
||||
if (state.phase == LocalCameraPhase.noNetwork) {
|
||||
if (!_noNetworkDialogShown) {
|
||||
_noNetworkDialogShown = true;
|
||||
_showNoNetworkDialog(context, state.message ?? LocalCameraCubit.msgNoNetwork);
|
||||
}
|
||||
} else {
|
||||
_noNetworkDialogShown = false;
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return PopScope(
|
||||
canPop: state.phase != LocalCameraPhase.playing &&
|
||||
state.phase != LocalCameraPhase.connecting,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
// 播放/连接中返回:先停流再返回到列表
|
||||
cubit.backToList();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
elevation: 0,
|
||||
leading: state.showVideo
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => cubit.backToList(),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'本地视频',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (state.phase == LocalCameraPhase.found ||
|
||||
state.phase == LocalCameraPhase.notFound)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||
onPressed: () => cubit.runDiscovery(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildContent(context, cubit, state)),
|
||||
_buildChain(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(
|
||||
BuildContext context,
|
||||
LocalCameraCubit cubit,
|
||||
LocalCameraState state,
|
||||
) {
|
||||
if (state.showVideo) {
|
||||
return _buildVideoArea(context, cubit, state);
|
||||
}
|
||||
switch (state.phase) {
|
||||
case LocalCameraPhase.searching:
|
||||
return _buildCenterState(
|
||||
icon: Icons.wifi_tethering,
|
||||
spinning: true,
|
||||
message: state.message ?? LocalCameraCubit.msgSearching,
|
||||
);
|
||||
case LocalCameraPhase.notFound:
|
||||
case LocalCameraPhase.noNetwork:
|
||||
return _buildCenterState(
|
||||
icon: Icons.videocam_off,
|
||||
message: state.message ?? LocalCameraCubit.msgNotFound,
|
||||
actionLabel: '重新搜索',
|
||||
onAction: () => cubit.runDiscovery(),
|
||||
);
|
||||
case LocalCameraPhase.found:
|
||||
return _buildCameraList(context, cubit, state);
|
||||
default:
|
||||
return _buildCenterState(
|
||||
icon: Icons.videocam_off,
|
||||
message: state.message ?? '准备就绪',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 播放器区域 =====
|
||||
Widget _buildVideoArea(
|
||||
BuildContext context,
|
||||
LocalCameraCubit cubit,
|
||||
LocalCameraState state,
|
||||
) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _buildPlayer(cubit),
|
||||
),
|
||||
// 连接中遮罩
|
||||
if (state.phase == LocalCameraPhase.connecting)
|
||||
Positioned.fill(
|
||||
child: _buildCenterState(
|
||||
icon: Icons.link,
|
||||
spinning: true,
|
||||
message: '正在连接摄像头...',
|
||||
dark: true,
|
||||
),
|
||||
),
|
||||
// 断流 / 播放失败遮罩 + 重新连接按钮
|
||||
if (state.phase == LocalCameraPhase.disconnected ||
|
||||
state.phase == LocalCameraPhase.playFailed)
|
||||
Positioned.fill(
|
||||
child: _buildCenterState(
|
||||
icon: Icons.error_outline,
|
||||
message: state.message ?? '播放失败',
|
||||
dark: true,
|
||||
actionLabel: '重新连接',
|
||||
onAction: () => cubit.reconnect(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 视频渲染(video_player / ExoPlayer)=====
|
||||
Widget _buildPlayer(LocalCameraCubit cubit) {
|
||||
final controller = cubit.videoController;
|
||||
// 控制器尚未创建 / 尚未初始化完成:先置黑底,由上层“连接中”遮罩提示
|
||||
if (controller == null || !controller.value.isInitialized) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: controller.value.aspectRatio,
|
||||
child: VideoPlayer(controller),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 摄像头列表 =====
|
||||
Widget _buildCameraList(
|
||||
BuildContext context,
|
||||
LocalCameraCubit cubit,
|
||||
LocalCameraState state,
|
||||
) {
|
||||
if (state.cameras.isEmpty) {
|
||||
return _buildCenterState(
|
||||
icon: Icons.videocam_off,
|
||||
message: LocalCameraCubit.msgNotFound,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Text(
|
||||
'发现 ${state.cameras.length} 个摄像头,点击查看实时画面',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: state.cameras.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final cam = state.cameras[i];
|
||||
return _CameraTile(
|
||||
camera: cam,
|
||||
onTap: () => cubit.selectCamera(cam),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCenterState({
|
||||
required IconData icon,
|
||||
required String message,
|
||||
bool spinning = false,
|
||||
bool dark = false,
|
||||
String? actionLabel,
|
||||
VoidCallback? onAction,
|
||||
}) {
|
||||
return Container(
|
||||
color: dark ? Colors.black.withValues(alpha: 0.65) : Colors.black,
|
||||
alignment: Alignment.center,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (spinning)
|
||||
const SizedBox(
|
||||
width: 42,
|
||||
height: 42,
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF165DFF),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(icon, color: Colors.white54, size: 52),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: 22),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(actionLabel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 链条:网络检查 → ONVIF发现 → 取流 → 拉流播放 =====
|
||||
Widget _buildChain(BuildContext context, LocalCameraState state) {
|
||||
final steps = <_ChainStep>[
|
||||
_ChainStep('网络检查', state.networkStep),
|
||||
_ChainStep('ONVIF发现', state.discoveryStep),
|
||||
_ChainStep('获取RTSP', state.streamStep),
|
||||
_ChainStep('拉流播放', state.playStep),
|
||||
];
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFF111111),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (int i = 0; i < steps.length; i++) ...[
|
||||
_ChainNode(step: steps[i]),
|
||||
if (i != steps.length - 1)
|
||||
_ChainConnector(active: steps[i].status == ChainStatus.success),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNoNetworkDialog(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('网络未就绪'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(context).pop(); // 关闭本地视频页
|
||||
},
|
||||
child: const Text('我知道了'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
context.read<LocalCameraCubit>().runDiscovery();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 链条子组件 =====================
|
||||
|
||||
class _ChainStep {
|
||||
final String label;
|
||||
final ChainStatus status;
|
||||
_ChainStep(this.label, this.status);
|
||||
}
|
||||
|
||||
class _ChainNode extends StatelessWidget {
|
||||
final _ChainStep step;
|
||||
const _ChainNode({required this.step});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color color;
|
||||
IconData? icon;
|
||||
bool loading = false;
|
||||
switch (step.status) {
|
||||
case ChainStatus.running:
|
||||
color = const Color(0xFF165DFF);
|
||||
loading = true;
|
||||
break;
|
||||
case ChainStatus.success:
|
||||
color = const Color(0xFF00B42A);
|
||||
icon = Icons.check;
|
||||
break;
|
||||
case ChainStatus.failed:
|
||||
color = const Color(0xFFF53F3F);
|
||||
icon = Icons.close;
|
||||
break;
|
||||
case ChainStatus.idle:
|
||||
color = Colors.white24;
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: loading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: CircularProgressIndicator(color: color, strokeWidth: 3),
|
||||
)
|
||||
: CircleAvatar(
|
||||
backgroundColor: color,
|
||||
radius: 17,
|
||||
child: icon != null
|
||||
? Icon(icon, color: Colors.white, size: 18)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
step.label,
|
||||
style: TextStyle(
|
||||
color: step.status == ChainStatus.idle ? Colors.white38 : Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChainConnector extends StatelessWidget {
|
||||
final bool active;
|
||||
const _ChainConnector({required this.active});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 22,
|
||||
height: 2,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
color: active ? const Color(0xFF00B42A) : Colors.white24,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 摄像头列表项 =====================
|
||||
|
||||
class _CameraTile extends StatelessWidget {
|
||||
final OnvifCamera camera;
|
||||
final VoidCallback onTap;
|
||||
const _CameraTile({required this.camera, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF1C1C1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.videocam,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
camera.ip,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
camera.model.isNotEmpty
|
||||
? camera.model
|
||||
: (camera.manufacturer.isNotEmpty
|
||||
? camera.manufacturer
|
||||
: '未知型号'),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.white38),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="io.flutter.plugins.videoplayer">
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="io.flutter.plugins.videoplayerexample">
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="video_player_example"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:name="io.flutter.embedding.android.FlutterActivity"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data android:name="flutterEmbedding" android:value="2"/>
|
||||
</application>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:video_player_example/main.dart' as app;
|
||||
|
||||
/// Entry point for integration tests that require espresso.
|
||||
@pragma('vm:entry-point')
|
||||
void integrationTestMain() {
|
||||
enableFlutterDriverExtension();
|
||||
|
||||
app.main();
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Since this file is lacking integration tests, this test ensures the example
|
||||
// app can be launched on an emulator/device.
|
||||
testWidgets('Launch Test', (WidgetTester tester) async {});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:video_player_android/video_player_android.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
const Duration _playDuration = Duration(seconds: 1);
|
||||
const String _videoAssetKey = 'assets/Butterfly-209.mp4';
|
||||
|
||||
// Returns the URL to load an asset from this example app as a network source.
|
||||
//
|
||||
// TODO(stuartmorgan): Convert this to a local `HttpServer` that vends the
|
||||
// assets directly, https://github.com/flutter/flutter/issues/95420
|
||||
String getUrlForAssetAsNetworkSource(String assetKey) {
|
||||
return 'https://github.com/flutter/packages/blob/'
|
||||
// This hash can be rolled forward to pick up newly-added assets.
|
||||
'2e1673307ff7454aff40b47024eaed49a9e77e81'
|
||||
'/packages/video_player/video_player/example/'
|
||||
'$assetKey'
|
||||
'?raw=true';
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
late AndroidVideoPlayer player;
|
||||
|
||||
setUp(() async {
|
||||
player = AndroidVideoPlayer();
|
||||
await player.init();
|
||||
});
|
||||
|
||||
testWidgets('registers expected implementation', (_) async {
|
||||
AndroidVideoPlayer.registerWith();
|
||||
expect(VideoPlayerPlatform.instance, isA<AndroidVideoPlayer>());
|
||||
});
|
||||
|
||||
testWidgets('initializes at the start', (_) async {
|
||||
final int playerId = (await player.create(
|
||||
DataSource(sourceType: DataSourceType.asset, asset: _videoAssetKey),
|
||||
))!;
|
||||
|
||||
expect(
|
||||
await _getDuration(player, playerId),
|
||||
const Duration(seconds: 7, milliseconds: 540),
|
||||
);
|
||||
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
|
||||
testWidgets('can be played', (WidgetTester tester) async {
|
||||
final int playerId = (await player.create(
|
||||
DataSource(sourceType: DataSourceType.asset, asset: _videoAssetKey),
|
||||
))!;
|
||||
|
||||
await player.play(playerId);
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
|
||||
expect(await player.getPosition(playerId), greaterThan(Duration.zero));
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
|
||||
testWidgets('can seek', (WidgetTester tester) async {
|
||||
final int playerId = (await player.create(
|
||||
DataSource(sourceType: DataSourceType.asset, asset: _videoAssetKey),
|
||||
))!;
|
||||
|
||||
await player.seekTo(playerId, const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
|
||||
expect(
|
||||
await player.getPosition(playerId),
|
||||
greaterThanOrEqualTo(const Duration(seconds: 3)),
|
||||
);
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
|
||||
testWidgets('can pause', (WidgetTester tester) async {
|
||||
final int playerId = (await player.create(
|
||||
DataSource(sourceType: DataSourceType.asset, asset: _videoAssetKey),
|
||||
))!;
|
||||
|
||||
await player.play(playerId);
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
|
||||
await player.pause(playerId);
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
final Duration pausedDuration = await player.getPosition(playerId);
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
|
||||
expect(await player.getPosition(playerId), pausedDuration);
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
|
||||
testWidgets('can play a video from a file', (WidgetTester tester) async {
|
||||
final Directory directory = await getTemporaryDirectory();
|
||||
final file = File('${directory.path}/video.mp4');
|
||||
await file.writeAsBytes(
|
||||
Uint8List.fromList(
|
||||
(await rootBundle.load(_videoAssetKey)).buffer.asUint8List(),
|
||||
),
|
||||
);
|
||||
|
||||
final int playerId = (await player.create(
|
||||
DataSource(sourceType: DataSourceType.file, uri: file.path),
|
||||
))!;
|
||||
|
||||
await player.play(playerId);
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
|
||||
expect(await player.getPosition(playerId), greaterThan(Duration.zero));
|
||||
await directory.delete(recursive: true);
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
|
||||
testWidgets('can play a video from network', (WidgetTester tester) async {
|
||||
final int playerId = (await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: getUrlForAssetAsNetworkSource(_videoAssetKey),
|
||||
),
|
||||
))!;
|
||||
|
||||
await player.play(playerId);
|
||||
await player.seekTo(playerId, const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle(_playDuration);
|
||||
await player.pause(playerId);
|
||||
|
||||
expect(await player.getPosition(playerId), greaterThan(Duration.zero));
|
||||
|
||||
final DurationRange range = await _getBufferingRange(player, playerId);
|
||||
expect(range.start, Duration.zero);
|
||||
expect(range.end, greaterThan(Duration.zero));
|
||||
|
||||
await player.dispose(playerId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Duration> _getDuration(AndroidVideoPlayer player, int playerId) {
|
||||
return player
|
||||
.videoEventsFor(playerId)
|
||||
.firstWhere((VideoEvent event) {
|
||||
return event.eventType == VideoEventType.initialized;
|
||||
})
|
||||
.then((VideoEvent event) {
|
||||
return event.duration!;
|
||||
});
|
||||
}
|
||||
|
||||
Future<DurationRange> _getBufferingRange(
|
||||
AndroidVideoPlayer player,
|
||||
int playerId,
|
||||
) {
|
||||
return player
|
||||
.videoEventsFor(playerId)
|
||||
.firstWhere((VideoEvent event) {
|
||||
return event.eventType == VideoEventType.bufferingUpdate;
|
||||
})
|
||||
.then((VideoEvent event) {
|
||||
return event.buffered!.first;
|
||||
});
|
||||
}
|
||||
383
packages/video_player_android/example/lib/main.dart
Normal file
383
packages/video_player_android/example/lib/main.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
import 'mini_controller.dart';
|
||||
|
||||
void main() {
|
||||
runApp(MaterialApp(home: _App()));
|
||||
}
|
||||
|
||||
class _App extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
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.videocam), text: 'RTSP'),
|
||||
Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: <Widget>[
|
||||
_ViewTypeTabBar(
|
||||
builder: (VideoViewType viewType) =>
|
||||
_BumbleBeeRemoteVideo(viewType),
|
||||
),
|
||||
_ViewTypeTabBar(
|
||||
builder: (VideoViewType viewType) => _RtspRemoteVideo(viewType),
|
||||
),
|
||||
_ViewTypeTabBar(
|
||||
builder: (VideoViewType viewType) =>
|
||||
_ButterFlyAssetVideo(viewType),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewTypeTabBar extends StatefulWidget {
|
||||
const _ViewTypeTabBar({required this.builder});
|
||||
|
||||
final Widget Function(VideoViewType) builder;
|
||||
|
||||
@override
|
||||
State<_ViewTypeTabBar> createState() => _ViewTypeTabBarState();
|
||||
}
|
||||
|
||||
class _ViewTypeTabBarState extends State<_ViewTypeTabBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabs: const <Widget>[
|
||||
Tab(icon: Icon(Icons.texture), text: 'Texture view'),
|
||||
Tab(icon: Icon(Icons.construction), text: 'Platform view'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: <Widget>[
|
||||
widget.builder(VideoViewType.textureView),
|
||||
widget.builder(VideoViewType.platformView),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ButterFlyAssetVideo extends StatefulWidget {
|
||||
const _ButterFlyAssetVideo(this.viewType);
|
||||
|
||||
final VideoViewType viewType;
|
||||
|
||||
@override
|
||||
_ButterFlyAssetVideoState createState() => _ButterFlyAssetVideoState();
|
||||
}
|
||||
|
||||
class _ButterFlyAssetVideoState extends State<_ButterFlyAssetVideo> {
|
||||
late MiniController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MiniController.asset(
|
||||
'assets/Butterfly-209.mp4',
|
||||
viewType: widget.viewType,
|
||||
);
|
||||
|
||||
_controller.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
_controller.initialize().then((_) => _controller.play());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(padding: const EdgeInsets.only(top: 20.0)),
|
||||
const Text('With assets mp4'),
|
||||
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 _BumbleBeeRemoteVideo extends StatefulWidget {
|
||||
const _BumbleBeeRemoteVideo(this.viewType);
|
||||
|
||||
final VideoViewType viewType;
|
||||
|
||||
@override
|
||||
_BumbleBeeRemoteVideoState createState() => _BumbleBeeRemoteVideoState();
|
||||
}
|
||||
|
||||
class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> {
|
||||
late MiniController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MiniController.network(
|
||||
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
|
||||
viewType: widget.viewType,
|
||||
);
|
||||
|
||||
_controller.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
_controller.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(padding: const EdgeInsets.only(top: 20.0)),
|
||||
const Text('With remote mp4'),
|
||||
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 _RtspRemoteVideo extends StatefulWidget {
|
||||
const _RtspRemoteVideo(this.viewType);
|
||||
|
||||
final VideoViewType viewType;
|
||||
|
||||
@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, viewType: widget.viewType);
|
||||
});
|
||||
|
||||
_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});
|
||||
|
||||
static const List<double> _examplePlaybackRates = <double>[
|
||||
0.25,
|
||||
0.5,
|
||||
1.0,
|
||||
1.5,
|
||||
2.0,
|
||||
3.0,
|
||||
5.0,
|
||||
10.0,
|
||||
];
|
||||
|
||||
final MiniController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 50),
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
child: controller.value.isPlaying
|
||||
? const SizedBox.shrink()
|
||||
: const ColoredBox(
|
||||
color: Colors.black26,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
key: ValueKey<String>('Play'),
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 100.0,
|
||||
semanticLabel: 'Play',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
controller.value.isPlaying ? controller.pause() : controller.play();
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: PopupMenuButton<double>(
|
||||
initialValue: controller.value.playbackSpeed,
|
||||
tooltip: 'Playback speed',
|
||||
onSelected: (double speed) {
|
||||
controller.setPlaybackSpeed(speed);
|
||||
},
|
||||
itemBuilder: (BuildContext context) {
|
||||
return <PopupMenuItem<double>>[
|
||||
for (final double speed in _examplePlaybackRates)
|
||||
PopupMenuItem<double>(value: speed, child: Text('${speed}x')),
|
||||
];
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
// Using less vertical padding as the text is also longer
|
||||
// horizontally, so it feels like it would need more spacing
|
||||
// horizontally (matching the aspect ratio of the video).
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Text('${controller.value.playbackSpeed}x'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
591
packages/video_player_android/example/lib/mini_controller.dart
Normal file
591
packages/video_player_android/example/lib/mini_controller.dart
Normal file
@@ -0,0 +1,591 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// TODO(stuartmorgan): Consider extracting this to a shared local (path-based)
|
||||
// package for use in all implementation packages.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math show max;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
VideoPlayerPlatform? _cachedPlatform;
|
||||
|
||||
VideoPlayerPlatform get _platform {
|
||||
if (_cachedPlatform == null) {
|
||||
_cachedPlatform = VideoPlayerPlatform.instance;
|
||||
_cachedPlatform!.init();
|
||||
}
|
||||
return _cachedPlatform!;
|
||||
}
|
||||
|
||||
/// The duration, current position, buffering state, error state and settings
|
||||
/// of a [MiniController].
|
||||
@immutable
|
||||
class VideoPlayerValue {
|
||||
/// Constructs a video with the given values. Only [duration] is required. The
|
||||
/// rest will initialize with default values when unset.
|
||||
const VideoPlayerValue({
|
||||
required this.duration,
|
||||
this.size = Size.zero,
|
||||
this.position = Duration.zero,
|
||||
this.buffered = const <DurationRange>[],
|
||||
this.isInitialized = false,
|
||||
this.isPlaying = false,
|
||||
this.isBuffering = false,
|
||||
this.playbackSpeed = 1.0,
|
||||
this.errorDescription,
|
||||
this.rotationCorrection = 0,
|
||||
});
|
||||
|
||||
/// Returns an instance for a video that hasn't been loaded.
|
||||
const VideoPlayerValue.uninitialized()
|
||||
: this(duration: Duration.zero, isInitialized: false);
|
||||
|
||||
/// Returns an instance with the given [errorDescription].
|
||||
const VideoPlayerValue.erroneous(String errorDescription)
|
||||
: this(
|
||||
duration: Duration.zero,
|
||||
isInitialized: false,
|
||||
errorDescription: errorDescription,
|
||||
);
|
||||
|
||||
/// The total duration of the video.
|
||||
///
|
||||
/// The duration is [Duration.zero] if the video hasn't been initialized.
|
||||
final Duration duration;
|
||||
|
||||
/// The current playback position.
|
||||
final Duration position;
|
||||
|
||||
/// The currently buffered ranges.
|
||||
final List<DurationRange> buffered;
|
||||
|
||||
/// True if the video is playing. False if it's paused.
|
||||
final bool isPlaying;
|
||||
|
||||
/// True if the video is currently buffering.
|
||||
final bool isBuffering;
|
||||
|
||||
/// The current speed of the playback.
|
||||
final double playbackSpeed;
|
||||
|
||||
/// A description of the error if present.
|
||||
///
|
||||
/// If [hasError] is false this is `null`.
|
||||
final String? errorDescription;
|
||||
|
||||
/// The [size] of the currently loaded video.
|
||||
final Size size;
|
||||
|
||||
/// Indicates whether or not the video has been loaded and is ready to play.
|
||||
final bool isInitialized;
|
||||
|
||||
/// Degrees to rotate the video (clockwise) so it is displayed correctly.
|
||||
final int rotationCorrection;
|
||||
|
||||
/// Indicates whether or not the video is in an error state. If this is true
|
||||
/// [errorDescription] should have information about the problem.
|
||||
bool get hasError => errorDescription != null;
|
||||
|
||||
/// Returns [size.width] / [size.height].
|
||||
///
|
||||
/// Will return `1.0` if:
|
||||
/// * [isInitialized] is `false`
|
||||
/// * [size.width], or [size.height] is equal to `0.0`
|
||||
/// * aspect ratio would be less than or equal to `0.0`
|
||||
double get aspectRatio {
|
||||
if (!isInitialized || size.width == 0 || size.height == 0) {
|
||||
return 1.0;
|
||||
}
|
||||
final double aspectRatio = size.width / size.height;
|
||||
if (aspectRatio <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
/// Returns a new instance that has the same values as this current instance,
|
||||
/// except for any overrides passed in as arguments to [copyWidth].
|
||||
VideoPlayerValue copyWith({
|
||||
Duration? duration,
|
||||
Size? size,
|
||||
Duration? position,
|
||||
List<DurationRange>? buffered,
|
||||
bool? isInitialized,
|
||||
bool? isPlaying,
|
||||
bool? isBuffering,
|
||||
double? playbackSpeed,
|
||||
String? errorDescription,
|
||||
int? rotationCorrection,
|
||||
}) {
|
||||
return VideoPlayerValue(
|
||||
duration: duration ?? this.duration,
|
||||
size: size ?? this.size,
|
||||
position: position ?? this.position,
|
||||
buffered: buffered ?? this.buffered,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
isPlaying: isPlaying ?? this.isPlaying,
|
||||
isBuffering: isBuffering ?? this.isBuffering,
|
||||
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
|
||||
errorDescription: errorDescription ?? this.errorDescription,
|
||||
rotationCorrection: rotationCorrection ?? this.rotationCorrection,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is VideoPlayerValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
duration == other.duration &&
|
||||
position == other.position &&
|
||||
listEquals(buffered, other.buffered) &&
|
||||
isPlaying == other.isPlaying &&
|
||||
isBuffering == other.isBuffering &&
|
||||
playbackSpeed == other.playbackSpeed &&
|
||||
errorDescription == other.errorDescription &&
|
||||
size == other.size &&
|
||||
isInitialized == other.isInitialized &&
|
||||
rotationCorrection == other.rotationCorrection;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
duration,
|
||||
position,
|
||||
buffered,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
playbackSpeed,
|
||||
errorDescription,
|
||||
size,
|
||||
isInitialized,
|
||||
rotationCorrection,
|
||||
);
|
||||
}
|
||||
|
||||
/// A very minimal version of `VideoPlayerController` for running the example
|
||||
/// without relying on `video_player`.
|
||||
class MiniController extends ValueNotifier<VideoPlayerValue> {
|
||||
/// Constructs a [MiniController] playing a video from an asset.
|
||||
///
|
||||
/// The name of the asset is given by the [dataSource] argument and must not be
|
||||
/// null. The [package] argument must be non-null when the asset comes from a
|
||||
/// package and null otherwise.
|
||||
MiniController.asset(
|
||||
this.dataSource, {
|
||||
this.package,
|
||||
this.viewType = VideoViewType.textureView,
|
||||
}) : dataSourceType = DataSourceType.asset,
|
||||
super(const VideoPlayerValue(duration: Duration.zero));
|
||||
|
||||
/// Constructs a [MiniController] playing a video from obtained from
|
||||
/// the network.
|
||||
MiniController.network(
|
||||
this.dataSource, {
|
||||
this.viewType = VideoViewType.textureView,
|
||||
}) : dataSourceType = DataSourceType.network,
|
||||
package = null,
|
||||
super(const VideoPlayerValue(duration: Duration.zero));
|
||||
|
||||
/// Constructs a [MiniController] playing a video from obtained from a file.
|
||||
MiniController.file(File file, {this.viewType = VideoViewType.textureView})
|
||||
: dataSource = Uri.file(file.absolute.path).toString(),
|
||||
dataSourceType = DataSourceType.file,
|
||||
package = null,
|
||||
super(const VideoPlayerValue(duration: Duration.zero));
|
||||
|
||||
/// The URI to the video file. This will be in different formats depending on
|
||||
/// the [DataSourceType] of the original video.
|
||||
final String dataSource;
|
||||
|
||||
/// Describes the type of data source this [MiniController]
|
||||
/// is constructed with.
|
||||
final DataSourceType dataSourceType;
|
||||
|
||||
/// Only set for [asset] videos. The package that the asset was loaded from.
|
||||
final String? package;
|
||||
|
||||
/// The type of view used to display the video.
|
||||
final VideoViewType viewType;
|
||||
|
||||
Timer? _timer;
|
||||
Completer<void>? _creatingCompleter;
|
||||
StreamSubscription<dynamic>? _eventSubscription;
|
||||
|
||||
/// The id of a player that hasn't been initialized.
|
||||
@visibleForTesting
|
||||
static const int kUninitializedPlayerId = -1;
|
||||
int _playerId = kUninitializedPlayerId;
|
||||
|
||||
/// This is just exposed for testing. It shouldn't be used by anyone depending
|
||||
/// on the plugin.
|
||||
@visibleForTesting
|
||||
int get playerId => _playerId;
|
||||
|
||||
/// Attempts to open the given [dataSource] and load metadata about the video.
|
||||
Future<void> initialize() async {
|
||||
_creatingCompleter = Completer<void>();
|
||||
|
||||
late DataSource dataSourceDescription;
|
||||
switch (dataSourceType) {
|
||||
case DataSourceType.asset:
|
||||
dataSourceDescription = DataSource(
|
||||
sourceType: DataSourceType.asset,
|
||||
asset: dataSource,
|
||||
package: package,
|
||||
);
|
||||
case DataSourceType.network:
|
||||
dataSourceDescription = DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: dataSource,
|
||||
);
|
||||
case DataSourceType.file:
|
||||
dataSourceDescription = DataSource(
|
||||
sourceType: DataSourceType.file,
|
||||
uri: dataSource,
|
||||
);
|
||||
case DataSourceType.contentUri:
|
||||
dataSourceDescription = DataSource(
|
||||
sourceType: DataSourceType.contentUri,
|
||||
uri: dataSource,
|
||||
);
|
||||
}
|
||||
|
||||
final creationOptions = VideoCreationOptions(
|
||||
dataSource: dataSourceDescription,
|
||||
viewType: viewType,
|
||||
);
|
||||
|
||||
_playerId =
|
||||
(await _platform.createWithOptions(creationOptions)) ??
|
||||
kUninitializedPlayerId;
|
||||
_creatingCompleter!.complete(null);
|
||||
final initializingCompleter = Completer<void>();
|
||||
|
||||
void eventListener(VideoEvent event) {
|
||||
switch (event.eventType) {
|
||||
case VideoEventType.initialized:
|
||||
value = value.copyWith(
|
||||
duration: event.duration,
|
||||
rotationCorrection: event.rotationCorrection,
|
||||
size: event.size,
|
||||
isInitialized: event.duration != null,
|
||||
);
|
||||
initializingCompleter.complete(null);
|
||||
_platform.setVolume(_playerId, 1.0);
|
||||
_platform.setLooping(_playerId, true);
|
||||
_applyPlayPause();
|
||||
case VideoEventType.completed:
|
||||
pause().then((void pauseResult) => seekTo(value.duration));
|
||||
case VideoEventType.bufferingUpdate:
|
||||
value = value.copyWith(buffered: event.buffered);
|
||||
case VideoEventType.bufferingStart:
|
||||
value = value.copyWith(isBuffering: true);
|
||||
case VideoEventType.bufferingEnd:
|
||||
value = value.copyWith(isBuffering: false);
|
||||
case VideoEventType.isPlayingStateUpdate:
|
||||
value = value.copyWith(isPlaying: event.isPlaying);
|
||||
case VideoEventType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void errorListener(Object obj) {
|
||||
final e = obj as PlatformException;
|
||||
value = VideoPlayerValue.erroneous(e.message!);
|
||||
_timer?.cancel();
|
||||
if (!initializingCompleter.isCompleted) {
|
||||
initializingCompleter.completeError(obj);
|
||||
}
|
||||
}
|
||||
|
||||
_eventSubscription = _platform
|
||||
.videoEventsFor(_playerId)
|
||||
.listen(eventListener, onError: errorListener);
|
||||
return initializingCompleter.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_creatingCompleter != null) {
|
||||
await _creatingCompleter!.future;
|
||||
_timer?.cancel();
|
||||
await _eventSubscription?.cancel();
|
||||
await _platform.dispose(_playerId);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Starts playing the video.
|
||||
Future<void> play() async {
|
||||
value = value.copyWith(isPlaying: true);
|
||||
await _applyPlayPause();
|
||||
}
|
||||
|
||||
/// Pauses the video.
|
||||
Future<void> pause() async {
|
||||
value = value.copyWith(isPlaying: false);
|
||||
await _applyPlayPause();
|
||||
}
|
||||
|
||||
Future<void> _applyPlayPause() async {
|
||||
_timer?.cancel();
|
||||
if (value.isPlaying) {
|
||||
await _platform.play(_playerId);
|
||||
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||
Timer timer,
|
||||
) async {
|
||||
final Duration? newPosition = await position;
|
||||
if (newPosition == null) {
|
||||
return;
|
||||
}
|
||||
_updatePosition(newPosition);
|
||||
});
|
||||
await _applyPlaybackSpeed();
|
||||
} else {
|
||||
await _platform.pause(_playerId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyPlaybackSpeed() async {
|
||||
if (value.isPlaying) {
|
||||
await _platform.setPlaybackSpeed(_playerId, value.playbackSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The position in the current video.
|
||||
Future<Duration?> get position async {
|
||||
return _platform.getPosition(_playerId);
|
||||
}
|
||||
|
||||
/// Sets the video's current timestamp to be at [position].
|
||||
Future<void> seekTo(Duration position) async {
|
||||
if (position > value.duration) {
|
||||
position = value.duration;
|
||||
} else if (position < Duration.zero) {
|
||||
position = Duration.zero;
|
||||
}
|
||||
await _platform.seekTo(_playerId, position);
|
||||
_updatePosition(position);
|
||||
}
|
||||
|
||||
/// Sets the playback speed.
|
||||
Future<void> setPlaybackSpeed(double speed) async {
|
||||
value = value.copyWith(playbackSpeed: speed);
|
||||
await _applyPlaybackSpeed();
|
||||
}
|
||||
|
||||
void _updatePosition(Duration position) {
|
||||
value = value.copyWith(position: position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget that displays the video controlled by [controller].
|
||||
class VideoPlayer extends StatefulWidget {
|
||||
/// Uses the given [controller] for all video rendered in this widget.
|
||||
const VideoPlayer(this.controller, {super.key});
|
||||
|
||||
/// The [MiniController] responsible for the video being rendered in
|
||||
/// this widget.
|
||||
final MiniController controller;
|
||||
|
||||
@override
|
||||
State<VideoPlayer> createState() => _VideoPlayerState();
|
||||
}
|
||||
|
||||
class _VideoPlayerState extends State<VideoPlayer> {
|
||||
_VideoPlayerState() {
|
||||
_listener = () {
|
||||
final int newPlayerId = widget.controller.playerId;
|
||||
if (newPlayerId != _playerId) {
|
||||
setState(() {
|
||||
_playerId = newPlayerId;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
late VoidCallback _listener;
|
||||
|
||||
late int _playerId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playerId = widget.controller.playerId;
|
||||
// Need to listen for initialization events since the actual player ID
|
||||
// becomes available after asynchronous initialization finishes.
|
||||
widget.controller.addListener(_listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
oldWidget.controller.removeListener(_listener);
|
||||
_playerId = widget.controller.playerId;
|
||||
widget.controller.addListener(_listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _playerId == MiniController.kUninitializedPlayerId
|
||||
? Container()
|
||||
: _VideoPlayerWithRotation(
|
||||
rotation: widget.controller.value.rotationCorrection,
|
||||
child: _platform.buildViewWithOptions(
|
||||
VideoViewOptions(playerId: _playerId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerWithRotation extends StatelessWidget {
|
||||
const _VideoPlayerWithRotation({required this.rotation, required this.child});
|
||||
|
||||
final int rotation;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (rotation == 0) {
|
||||
return child;
|
||||
}
|
||||
return RotatedBox(quarterTurns: rotation ~/ 90, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoScrubber extends StatefulWidget {
|
||||
const _VideoScrubber({required this.child, required this.controller});
|
||||
|
||||
final Widget child;
|
||||
final MiniController controller;
|
||||
|
||||
@override
|
||||
_VideoScrubberState createState() => _VideoScrubberState();
|
||||
}
|
||||
|
||||
class _VideoScrubberState extends State<_VideoScrubber> {
|
||||
MiniController get controller => widget.controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void seekToRelativePosition(Offset globalPosition) {
|
||||
final box = context.findRenderObject()! as RenderBox;
|
||||
final Offset tapPos = box.globalToLocal(globalPosition);
|
||||
final double relative = tapPos.dx / box.size.width;
|
||||
final Duration position = controller.value.duration * relative;
|
||||
controller.seekTo(position);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: widget.child,
|
||||
onTapDown: (TapDownDetails details) {
|
||||
if (controller.value.isInitialized) {
|
||||
seekToRelativePosition(details.globalPosition);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the play/buffering status of the video controlled by [controller].
|
||||
class VideoProgressIndicator extends StatefulWidget {
|
||||
/// Construct an instance that displays the play/buffering status of the video
|
||||
/// controlled by [controller].
|
||||
const VideoProgressIndicator(this.controller, {super.key});
|
||||
|
||||
/// The [MiniController] that actually associates a video with this
|
||||
/// widget.
|
||||
final MiniController controller;
|
||||
|
||||
@override
|
||||
State<VideoProgressIndicator> createState() => _VideoProgressIndicatorState();
|
||||
}
|
||||
|
||||
class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
|
||||
_VideoProgressIndicatorState() {
|
||||
listener = () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
late VoidCallback listener;
|
||||
|
||||
MiniController get controller => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.removeListener(listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const playedColor = Color.fromRGBO(255, 0, 0, 0.7);
|
||||
const bufferedColor = Color.fromRGBO(50, 50, 200, 0.2);
|
||||
const backgroundColor = Color.fromRGBO(200, 200, 200, 0.5);
|
||||
|
||||
final Widget progressIndicator;
|
||||
if (controller.value.isInitialized) {
|
||||
final int duration = controller.value.duration.inMilliseconds;
|
||||
final int position = controller.value.position.inMilliseconds;
|
||||
|
||||
final double maxBuffering = duration == 0.0
|
||||
? 0.0
|
||||
: controller.value.buffered
|
||||
.map((DurationRange range) => range.end.inMilliseconds)
|
||||
.fold(0, math.max) /
|
||||
duration;
|
||||
|
||||
progressIndicator = Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
LinearProgressIndicator(
|
||||
value: maxBuffering,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(bufferedColor),
|
||||
backgroundColor: backgroundColor,
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
value: duration == 0.0 ? 0.0 : position / duration,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(playedColor),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
progressIndicator = const LinearProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(playedColor),
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
}
|
||||
return _VideoScrubber(
|
||||
controller: controller,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 5.0),
|
||||
child: progressIndicator,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
Future<void> main() => integrationDriver();
|
||||
524
packages/video_player_android/lib/src/android_video_player.dart
Normal file
524
packages/video_player_android/lib/src/android_video_player.dart
Normal file
@@ -0,0 +1,524 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
import 'messages.g.dart' hide videoEvents;
|
||||
import 'messages.g.dart' as pigeon show videoEvents;
|
||||
import 'platform_view_player.dart';
|
||||
|
||||
/// The non-test implementation of `_apiProvider`.
|
||||
VideoPlayerInstanceApi _productionApiProvider(int playerId) {
|
||||
return VideoPlayerInstanceApi(messageChannelSuffix: playerId.toString());
|
||||
}
|
||||
|
||||
/// The non-test implementation of `_videoEventStreamProvider`.
|
||||
Stream<PlatformVideoEvent> _productionVideoEventStreamProvider(
|
||||
String streamIdentifier,
|
||||
) {
|
||||
return pigeon.videoEvents(instanceName: streamIdentifier);
|
||||
}
|
||||
|
||||
/// An Android implementation of [VideoPlayerPlatform] that uses the
|
||||
/// Pigeon-generated [VideoPlayerApi].
|
||||
class AndroidVideoPlayer extends VideoPlayerPlatform {
|
||||
/// Creates a new Android video player implementation instance.
|
||||
AndroidVideoPlayer({
|
||||
@visibleForTesting AndroidVideoPlayerApi? pluginApi,
|
||||
@visibleForTesting
|
||||
VideoPlayerInstanceApi Function(int playerId)? playerApiProvider,
|
||||
Stream<PlatformVideoEvent> Function(String streamIdentifier)?
|
||||
videoEventStreamProvider,
|
||||
}) : _api = pluginApi ?? AndroidVideoPlayerApi(),
|
||||
_playerApiProvider = playerApiProvider ?? _productionApiProvider,
|
||||
_videoEventStreamProvider =
|
||||
videoEventStreamProvider ?? _productionVideoEventStreamProvider;
|
||||
|
||||
final AndroidVideoPlayerApi _api;
|
||||
// A method to create VideoPlayerInstanceApi instances, which can be
|
||||
// overridden for testing.
|
||||
final VideoPlayerInstanceApi Function(int playerId) _playerApiProvider;
|
||||
// A method to create video event stream instances, which can be
|
||||
// overridden for testing.
|
||||
final Stream<PlatformVideoEvent> Function(String streamIdentifier)
|
||||
_videoEventStreamProvider;
|
||||
|
||||
final Map<int, _PlayerInstance> _players = <int, _PlayerInstance>{};
|
||||
|
||||
/// Registers this class as the default instance of [PathProviderPlatform].
|
||||
static void registerWith() {
|
||||
VideoPlayerPlatform.instance = AndroidVideoPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> init() {
|
||||
return _api.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose(int playerId) async {
|
||||
final _PlayerInstance? player = _players.remove(playerId);
|
||||
await player?.dispose();
|
||||
await _api.dispose(playerId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> create(DataSource dataSource) {
|
||||
return createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: dataSource,
|
||||
// Compatibility; "create" is always a textureView (createWithOptions
|
||||
// allows selecting).
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> createWithOptions(VideoCreationOptions options) async {
|
||||
final DataSource dataSource = options.dataSource;
|
||||
|
||||
String? uri;
|
||||
PlatformVideoFormat? formatHint;
|
||||
final Map<String, String> httpHeaders = dataSource.httpHeaders;
|
||||
final String? userAgent = _userAgentFromHeaders(httpHeaders);
|
||||
switch (dataSource.sourceType) {
|
||||
case DataSourceType.asset:
|
||||
final String? asset = dataSource.asset;
|
||||
if (asset == null) {
|
||||
throw ArgumentError(
|
||||
'"asset" must be non-null for an asset data source',
|
||||
);
|
||||
}
|
||||
final String key = await _api.getLookupKeyForAsset(
|
||||
asset,
|
||||
dataSource.package,
|
||||
);
|
||||
uri = 'asset:///$key';
|
||||
case DataSourceType.network:
|
||||
uri = dataSource.uri;
|
||||
formatHint = _platformVideoFormatFromVideoFormat(dataSource.formatHint);
|
||||
case DataSourceType.file:
|
||||
case DataSourceType.contentUri:
|
||||
uri = dataSource.uri;
|
||||
}
|
||||
if (uri == null) {
|
||||
throw ArgumentError('Unable to construct a video asset from $options');
|
||||
}
|
||||
final pigeonCreationOptions = CreationOptions(
|
||||
uri: uri,
|
||||
httpHeaders: httpHeaders,
|
||||
userAgent: userAgent,
|
||||
formatHint: formatHint,
|
||||
);
|
||||
|
||||
final int playerId;
|
||||
final VideoPlayerViewState state;
|
||||
switch (options.viewType) {
|
||||
case VideoViewType.textureView:
|
||||
final TexturePlayerIds ids = await _api.createForTextureView(
|
||||
pigeonCreationOptions,
|
||||
);
|
||||
playerId = ids.playerId;
|
||||
state = VideoPlayerTextureViewState(textureId: ids.textureId);
|
||||
case VideoViewType.platformView:
|
||||
playerId = await _api.createForPlatformView(pigeonCreationOptions);
|
||||
state = const VideoPlayerPlatformViewState();
|
||||
}
|
||||
ensurePlayerInitialized(playerId, state);
|
||||
|
||||
return playerId;
|
||||
}
|
||||
|
||||
// Returns the user agent to use with ExoPlayer for the given headers.
|
||||
String? _userAgentFromHeaders(Map<String, String> httpHeaders) {
|
||||
// TODO(stuartmorgan): HTTP headers are case-insensitive, so this should be
|
||||
// adjusted to find any entry where the key has a case-insensitive match.
|
||||
const userAgentKey = 'User-Agent';
|
||||
// TODO(stuartmorgan): Investigate removing this. The use of a hard-coded
|
||||
// default agent dates back to the original ExoPlayer implementation of the
|
||||
// plugin, but it's not clear why the default isn't null, which would let
|
||||
// ExoPlayer use its own default value.
|
||||
const defaultUserAgent = 'ExoPlayer';
|
||||
return httpHeaders[userAgentKey] ?? defaultUserAgent;
|
||||
}
|
||||
|
||||
/// Returns the player instance for [playerId], creating it if it doesn't
|
||||
/// already exist.
|
||||
@visibleForTesting
|
||||
void ensurePlayerInitialized(int playerId, VideoPlayerViewState viewState) {
|
||||
_players.putIfAbsent(playerId, () {
|
||||
return _PlayerInstance(
|
||||
_playerApiProvider(playerId),
|
||||
viewState,
|
||||
videoEventStream: _videoEventStreamProvider(playerId.toString()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setLooping(int playerId, bool looping) {
|
||||
return _playerWith(id: playerId).setLooping(looping);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play(int playerId) {
|
||||
return _playerWith(id: playerId).play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(int playerId) {
|
||||
return _playerWith(id: playerId).pause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(int playerId, double volume) {
|
||||
return _playerWith(id: playerId).setVolume(volume);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPlaybackSpeed(int playerId, double speed) {
|
||||
assert(speed > 0);
|
||||
|
||||
return _playerWith(id: playerId).setPlaybackSpeed(speed);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seekTo(int playerId, Duration position) {
|
||||
return _playerWith(id: playerId).seekTo(position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Duration> getPosition(int playerId) async {
|
||||
return _playerWith(id: playerId).getPosition();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<VideoEvent> videoEventsFor(int playerId) {
|
||||
return _playerWith(id: playerId).videoEvents();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildView(int playerId) {
|
||||
return buildViewWithOptions(VideoViewOptions(playerId: playerId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildViewWithOptions(VideoViewOptions options) {
|
||||
final int playerId = options.playerId;
|
||||
final VideoPlayerViewState viewState = _playerWith(id: playerId).viewState;
|
||||
|
||||
return switch (viewState) {
|
||||
VideoPlayerTextureViewState(:final int textureId) => Texture(
|
||||
textureId: textureId,
|
||||
),
|
||||
VideoPlayerPlatformViewState() => PlatformViewPlayer(playerId: playerId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMixWithOthers(bool mixWithOthers) {
|
||||
return _api.setMixWithOthers(mixWithOthers);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VideoAudioTrack>> getAudioTracks(int playerId) async {
|
||||
final NativeAudioTrackData nativeData = await _playerWith(
|
||||
id: playerId,
|
||||
).getAudioTracks();
|
||||
final tracks = <VideoAudioTrack>[];
|
||||
|
||||
// Convert ExoPlayer tracks to VideoAudioTrack
|
||||
if (nativeData.exoPlayerTracks != null) {
|
||||
for (final ExoPlayerAudioTrackData track in nativeData.exoPlayerTracks!) {
|
||||
// Construct a string ID from groupIndex and trackIndex for compatibility
|
||||
final trackId = '${track.groupIndex}_${track.trackIndex}';
|
||||
tracks.add(
|
||||
VideoAudioTrack(
|
||||
id: trackId,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
isSelected: track.isSelected,
|
||||
bitrate: track.bitrate,
|
||||
sampleRate: track.sampleRate,
|
||||
channelCount: track.channelCount,
|
||||
codec: track.codec,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(int playerId, String trackId) {
|
||||
return _playerWith(id: playerId).selectAudioTrack(trackId);
|
||||
}
|
||||
|
||||
@override
|
||||
bool isAudioTrackSupportAvailable() {
|
||||
// Android with ExoPlayer supports audio track selection
|
||||
return true;
|
||||
}
|
||||
|
||||
_PlayerInstance _playerWith({required int id}) {
|
||||
final _PlayerInstance? player = _players[id];
|
||||
return player ?? (throw StateError('No active player with ID $id.'));
|
||||
}
|
||||
|
||||
PlatformVideoFormat? _platformVideoFormatFromVideoFormat(
|
||||
VideoFormat? format,
|
||||
) {
|
||||
return switch (format) {
|
||||
VideoFormat.dash => PlatformVideoFormat.dash,
|
||||
VideoFormat.hls => PlatformVideoFormat.hls,
|
||||
VideoFormat.ss => PlatformVideoFormat.ss,
|
||||
VideoFormat.other => null,
|
||||
// Include a catch-all, since the enum comes from another package, so
|
||||
// this code must handle the possibility of a new enum value.
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of a video player, corresponding to a single player ID in
|
||||
/// [AndroidVideoPlayer].
|
||||
class _PlayerInstance {
|
||||
/// Creates a new instance of [_PlayerInstance] corresponding to the given
|
||||
/// API instance.
|
||||
_PlayerInstance(
|
||||
this._api,
|
||||
this.viewState, {
|
||||
required Stream<PlatformVideoEvent> videoEventStream,
|
||||
}) {
|
||||
_eventSubscription = videoEventStream.listen(
|
||||
_onStreamEvent,
|
||||
onError: (Object e) {
|
||||
_setBuffering(false);
|
||||
_eventStreamController.addError(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final VideoPlayerInstanceApi _api;
|
||||
final StreamController<VideoEvent> _eventStreamController =
|
||||
StreamController<VideoEvent>();
|
||||
late final StreamSubscription<dynamic> _eventSubscription;
|
||||
bool _isDisposed = false;
|
||||
Timer? _bufferPollingTimer;
|
||||
int _lastBufferPosition = -1;
|
||||
bool _isBuffering = false;
|
||||
Completer<void>? _audioTrackSelectionCompleter;
|
||||
|
||||
final VideoPlayerViewState viewState;
|
||||
|
||||
Future<void> setLooping(bool looping) {
|
||||
return _api.setLooping(looping);
|
||||
}
|
||||
|
||||
Future<void> play() {
|
||||
return _api.play();
|
||||
}
|
||||
|
||||
Future<void> pause() {
|
||||
return _api.pause();
|
||||
}
|
||||
|
||||
Future<void> setVolume(double volume) {
|
||||
return _api.setVolume(volume);
|
||||
}
|
||||
|
||||
Future<void> setPlaybackSpeed(double speed) {
|
||||
return _api.setPlaybackSpeed(speed);
|
||||
}
|
||||
|
||||
Future<void> seekTo(Duration position) {
|
||||
return _api.seekTo(position.inMilliseconds);
|
||||
}
|
||||
|
||||
Future<Duration> getPosition() async {
|
||||
return Duration(milliseconds: await _api.getCurrentPosition());
|
||||
}
|
||||
|
||||
Stream<VideoEvent> videoEvents() {
|
||||
return _eventStreamController.stream;
|
||||
}
|
||||
|
||||
Future<NativeAudioTrackData> getAudioTracks() {
|
||||
return _api.getAudioTracks();
|
||||
}
|
||||
|
||||
Future<void> selectAudioTrack(String trackId) async {
|
||||
// Parse the trackId to get groupIndex and trackIndex
|
||||
final List<String> parts = trackId.split('_');
|
||||
if (parts.length != 2) {
|
||||
throw ArgumentError(
|
||||
'Invalid trackId format: "$trackId". Expected format: "groupIndex_trackIndex"',
|
||||
);
|
||||
}
|
||||
|
||||
final int groupIndex = int.parse(parts[0]);
|
||||
final int trackIndex = int.parse(parts[1]);
|
||||
|
||||
// Create a completer to wait for the track selection to complete
|
||||
_audioTrackSelectionCompleter = Completer<void>();
|
||||
|
||||
try {
|
||||
await _api.selectAudioTrack(groupIndex, trackIndex);
|
||||
|
||||
// Wait for the onTracksChanged event from ExoPlayer with a timeout
|
||||
await _audioTrackSelectionCompleter!.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
// If we timeout, just continue - the track may still have been selected
|
||||
// This is a fallback in case the event doesn't arrive for some reason
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
_audioTrackSelectionCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_isDisposed = true;
|
||||
_bufferPollingTimer?.cancel();
|
||||
await _eventSubscription.cancel();
|
||||
}
|
||||
|
||||
void _setBuffering(bool buffering) {
|
||||
if (buffering != _isBuffering) {
|
||||
_isBuffering = buffering;
|
||||
|
||||
_eventStreamController.add(
|
||||
VideoEvent(
|
||||
eventType: buffering
|
||||
? VideoEventType.bufferingStart
|
||||
: VideoEventType.bufferingEnd,
|
||||
),
|
||||
);
|
||||
// Trigger an extra buffer position check, so that clients have an
|
||||
// accurate reporting of the current buffering state.
|
||||
_api.getBufferedPosition().then((int position) {
|
||||
if (!_isDisposed) {
|
||||
_updateBufferPosition(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a buffering update if the buffer position has changed since the
|
||||
/// last check.
|
||||
void _updateBufferPosition(int bufferPosition) {
|
||||
if (bufferPosition != _lastBufferPosition) {
|
||||
_lastBufferPosition = bufferPosition;
|
||||
_eventStreamController.add(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: _bufferRangeForPosition(bufferPosition),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onStreamEvent(PlatformVideoEvent event) {
|
||||
switch (event) {
|
||||
case InitializationEvent _:
|
||||
_eventStreamController.add(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: Duration(milliseconds: event.duration),
|
||||
size: Size(event.width.toDouble(), event.height.toDouble()),
|
||||
rotationCorrection: event.rotationCorrection,
|
||||
),
|
||||
);
|
||||
|
||||
// Start polling for buffer position, since there is no buffer position
|
||||
// event to listen to.
|
||||
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), (
|
||||
Timer timer,
|
||||
) async {
|
||||
final int position = await _api.getBufferedPosition();
|
||||
if (!_isDisposed) {
|
||||
_updateBufferPosition(position);
|
||||
}
|
||||
});
|
||||
case IsPlayingStateEvent _:
|
||||
_eventStreamController.add(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.isPlayingStateUpdate,
|
||||
isPlaying: event.isPlaying,
|
||||
),
|
||||
);
|
||||
case PlaybackStateChangeEvent _:
|
||||
switch (event.state) {
|
||||
case PlatformPlaybackState.idle:
|
||||
// This is currently only used for buffering below.
|
||||
break;
|
||||
case PlatformPlaybackState.buffering:
|
||||
_setBuffering(true);
|
||||
case PlatformPlaybackState.ready:
|
||||
// On the Dart side, this is only used for buffering below. On the
|
||||
// native side it drives the 'initialized' event; that can't
|
||||
// currently be moved here since gathering the initialization state
|
||||
// should be synchronous with the state change.
|
||||
break;
|
||||
case PlatformPlaybackState.ended:
|
||||
_eventStreamController.add(
|
||||
VideoEvent(eventType: VideoEventType.completed),
|
||||
);
|
||||
case PlatformPlaybackState.unknown:
|
||||
// Ignore unknown states. This isn't an error since the media
|
||||
// framework could add new states in the future.
|
||||
break;
|
||||
}
|
||||
// Any state other than buffering should end the buffering state.
|
||||
if (event.state != PlatformPlaybackState.buffering) {
|
||||
_setBuffering(false);
|
||||
}
|
||||
case AudioTrackChangedEvent _:
|
||||
// Complete the audio track selection completer if it exists
|
||||
// This signals that the track selection has completed
|
||||
if (_audioTrackSelectionCompleter != null &&
|
||||
!_audioTrackSelectionCompleter!.isCompleted) {
|
||||
_audioTrackSelectionCompleter!.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turns a single buffer position, which is what ExoPlayer reports, into the
|
||||
// DurationRange array expected by [VideoEventType.bufferingUpdate].
|
||||
List<DurationRange> _bufferRangeForPosition(int milliseconds) {
|
||||
return <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration(milliseconds: milliseconds)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Base class representing the state of a video player view.
|
||||
@visibleForTesting
|
||||
@immutable
|
||||
sealed class VideoPlayerViewState {
|
||||
const VideoPlayerViewState();
|
||||
}
|
||||
|
||||
/// Represents the state of a video player view that uses a texture.
|
||||
@visibleForTesting
|
||||
final class VideoPlayerTextureViewState extends VideoPlayerViewState {
|
||||
/// Creates a new instance of [VideoPlayerTextureViewState].
|
||||
const VideoPlayerTextureViewState({required this.textureId});
|
||||
|
||||
/// The ID of the texture used by the video player.
|
||||
final int textureId;
|
||||
}
|
||||
|
||||
/// Represents the state of a video player view that uses a platform view.
|
||||
@visibleForTesting
|
||||
final class VideoPlayerPlatformViewState extends VideoPlayerViewState {
|
||||
/// Creates a new instance of [VideoPlayerPlatformViewState].
|
||||
const VideoPlayerPlatformViewState();
|
||||
}
|
||||
1158
packages/video_player_android/lib/src/messages.g.dart
Normal file
1158
packages/video_player_android/lib/src/messages.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'messages.g.dart';
|
||||
|
||||
/// A widget that displays a video player using a platform view.
|
||||
class PlatformViewPlayer extends StatelessWidget {
|
||||
/// Creates a new instance of [PlatformViewPlayer].
|
||||
const PlatformViewPlayer({super.key, required this.playerId});
|
||||
|
||||
/// The ID of the player.
|
||||
final int playerId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const viewType = 'plugins.flutter.dev/video_player_android';
|
||||
final creationParams = PlatformVideoViewCreationParams(playerId: playerId);
|
||||
|
||||
// IgnorePointer so that GestureDetector can be used above the platform view.
|
||||
return IgnorePointer(
|
||||
child: PlatformViewLink(
|
||||
viewType: viewType,
|
||||
surfaceFactory:
|
||||
(BuildContext context, PlatformViewController controller) {
|
||||
return AndroidViewSurface(
|
||||
controller: controller as AndroidViewController,
|
||||
gestureRecognizers:
|
||||
const <Factory<OneSequenceGestureRecognizer>>{},
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||
);
|
||||
},
|
||||
onCreatePlatformView: (PlatformViewCreationParams params) {
|
||||
return PlatformViewsService.initSurfaceAndroidView(
|
||||
id: params.id,
|
||||
viewType: viewType,
|
||||
layoutDirection:
|
||||
Directionality.maybeOf(context) ?? TextDirection.ltr,
|
||||
creationParams: creationParams,
|
||||
creationParamsCodec: AndroidVideoPlayerApi.pigeonChannelCodec,
|
||||
onFocus: () => params.onFocusChanged(true),
|
||||
)
|
||||
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
|
||||
..create();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
export 'src/android_video_player.dart';
|
||||
200
packages/video_player_android/pigeons/messages.dart
Normal file
200
packages/video_player_android/pigeons/messages.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/src/messages.g.dart',
|
||||
kotlinOut:
|
||||
'android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'io.flutter.plugins.videoplayer'),
|
||||
copyrightHeader: 'pigeons/copyright.txt',
|
||||
),
|
||||
)
|
||||
/// Pigeon equivalent of video_platform_interface's VideoFormat.
|
||||
enum PlatformVideoFormat { dash, hls, ss }
|
||||
|
||||
/// Pigeon equivalent of Player's playback state.
|
||||
/// https://developer.android.com/media/media3/exoplayer/listening-to-player-events#playback-state
|
||||
enum PlatformPlaybackState { idle, buffering, ready, ended, unknown }
|
||||
|
||||
sealed class PlatformVideoEvent {}
|
||||
|
||||
/// Sent when the video is initialized and ready to play.
|
||||
class InitializationEvent extends PlatformVideoEvent {
|
||||
/// The video duration in milliseconds.
|
||||
late final int duration;
|
||||
|
||||
/// The width of the video in pixels.
|
||||
late final int width;
|
||||
|
||||
/// The height of the video in pixels.
|
||||
late final int height;
|
||||
|
||||
/// The rotation that should be applied during playback.
|
||||
late final int rotationCorrection;
|
||||
}
|
||||
|
||||
/// Sent when the video state changes.
|
||||
///
|
||||
/// Corresponds to ExoPlayer's onPlaybackStateChanged.
|
||||
class PlaybackStateChangeEvent extends PlatformVideoEvent {
|
||||
late final PlatformPlaybackState state;
|
||||
}
|
||||
|
||||
/// Sent when the video starts or stops playing.
|
||||
///
|
||||
/// Corresponds to ExoPlayer's onIsPlayingChanged.
|
||||
class IsPlayingStateEvent extends PlatformVideoEvent {
|
||||
late final bool isPlaying;
|
||||
}
|
||||
|
||||
/// Sent when audio tracks change.
|
||||
///
|
||||
/// This includes when the selected audio track changes after calling selectAudioTrack.
|
||||
/// Corresponds to ExoPlayer's onTracksChanged.
|
||||
class AudioTrackChangedEvent extends PlatformVideoEvent {
|
||||
/// The ID of the newly selected audio track, if any.
|
||||
late final String? selectedTrackId;
|
||||
}
|
||||
|
||||
/// Information passed to the platform view creation.
|
||||
class PlatformVideoViewCreationParams {
|
||||
const PlatformVideoViewCreationParams({required this.playerId});
|
||||
|
||||
final int playerId;
|
||||
}
|
||||
|
||||
class CreationOptions {
|
||||
CreationOptions({required this.uri, required this.httpHeaders});
|
||||
String uri;
|
||||
PlatformVideoFormat? formatHint;
|
||||
Map<String, String> httpHeaders;
|
||||
String? userAgent;
|
||||
}
|
||||
|
||||
class TexturePlayerIds {
|
||||
TexturePlayerIds({required this.playerId, required this.textureId});
|
||||
|
||||
final int playerId;
|
||||
final int textureId;
|
||||
}
|
||||
|
||||
class PlaybackState {
|
||||
PlaybackState({required this.playPosition, required this.bufferPosition});
|
||||
|
||||
/// The current playback position, in milliseconds.
|
||||
final int playPosition;
|
||||
|
||||
/// The current buffer position, in milliseconds.
|
||||
final int bufferPosition;
|
||||
}
|
||||
|
||||
/// Represents an audio track in a video.
|
||||
class AudioTrackMessage {
|
||||
AudioTrackMessage({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.language,
|
||||
required this.isSelected,
|
||||
this.bitrate,
|
||||
this.sampleRate,
|
||||
this.channelCount,
|
||||
this.codec,
|
||||
});
|
||||
|
||||
String id;
|
||||
String label;
|
||||
String language;
|
||||
bool isSelected;
|
||||
int? bitrate;
|
||||
int? sampleRate;
|
||||
int? channelCount;
|
||||
String? codec;
|
||||
}
|
||||
|
||||
/// Raw audio track data from ExoPlayer Format objects.
|
||||
class ExoPlayerAudioTrackData {
|
||||
ExoPlayerAudioTrackData({
|
||||
required this.groupIndex,
|
||||
required this.trackIndex,
|
||||
this.label,
|
||||
this.language,
|
||||
required this.isSelected,
|
||||
this.bitrate,
|
||||
this.sampleRate,
|
||||
this.channelCount,
|
||||
this.codec,
|
||||
});
|
||||
|
||||
int groupIndex;
|
||||
int trackIndex;
|
||||
String? label;
|
||||
String? language;
|
||||
bool isSelected;
|
||||
int? bitrate;
|
||||
int? sampleRate;
|
||||
int? channelCount;
|
||||
String? codec;
|
||||
}
|
||||
|
||||
/// Container for raw audio track data from Android ExoPlayer.
|
||||
class NativeAudioTrackData {
|
||||
NativeAudioTrackData({this.exoPlayerTracks});
|
||||
|
||||
/// ExoPlayer-based tracks
|
||||
List<ExoPlayerAudioTrackData>? exoPlayerTracks;
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class AndroidVideoPlayerApi {
|
||||
void initialize();
|
||||
// Creates a new player using a platform view for rendering and returns its
|
||||
// ID.
|
||||
int createForPlatformView(CreationOptions options);
|
||||
// Creates a new player using a texture for rendering and returns its IDs.
|
||||
TexturePlayerIds createForTextureView(CreationOptions options);
|
||||
void dispose(int playerId);
|
||||
void setMixWithOthers(bool mixWithOthers);
|
||||
String getLookupKeyForAsset(String asset, String? packageName);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class VideoPlayerInstanceApi {
|
||||
/// Sets whether to automatically loop playback of the video.
|
||||
void setLooping(bool looping);
|
||||
|
||||
/// Sets the volume, with 0.0 being muted and 1.0 being full volume.
|
||||
void setVolume(double volume);
|
||||
|
||||
/// Sets the playback speed as a multiple of normal speed.
|
||||
void setPlaybackSpeed(double speed);
|
||||
|
||||
/// Begins playback if the video is not currently playing.
|
||||
void play();
|
||||
|
||||
/// Pauses playback if the video is currently playing.
|
||||
void pause();
|
||||
|
||||
/// Seeks to the given playback position, in milliseconds.
|
||||
void seekTo(int position);
|
||||
|
||||
/// Returns the current playback position, in milliseconds.
|
||||
int getCurrentPosition();
|
||||
|
||||
/// Returns the current buffer position, in milliseconds.
|
||||
int getBufferedPosition();
|
||||
|
||||
/// Gets the available audio tracks for the video.
|
||||
NativeAudioTrackData getAudioTracks();
|
||||
|
||||
/// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex]
|
||||
void selectAudioTrack(int groupIndex, int trackIndex);
|
||||
}
|
||||
|
||||
@EventChannelApi()
|
||||
abstract class VideoEventChannel {
|
||||
PlatformVideoEvent videoEvents();
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
// Copyright 2013 The Flutter Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:video_player_android/src/messages.g.dart';
|
||||
import 'package:video_player_android/src/platform_view_player.dart';
|
||||
import 'package:video_player_android/video_player_android.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
import 'android_video_player_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks(<MockSpec<Object>>[
|
||||
MockSpec<AndroidVideoPlayerApi>(),
|
||||
MockSpec<VideoPlayerInstanceApi>(),
|
||||
])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Provide dummy values for audio track types
|
||||
provideDummy<NativeAudioTrackData>(
|
||||
NativeAudioTrackData(exoPlayerTracks: <ExoPlayerAudioTrackData>[]),
|
||||
);
|
||||
provideDummy<Future<NativeAudioTrackData>>(
|
||||
Future<NativeAudioTrackData>.value(
|
||||
NativeAudioTrackData(exoPlayerTracks: <ExoPlayerAudioTrackData>[]),
|
||||
),
|
||||
);
|
||||
provideDummy<Future<void>>(Future<void>.value());
|
||||
|
||||
(AndroidVideoPlayer, MockAndroidVideoPlayerApi, MockVideoPlayerInstanceApi)
|
||||
setUpMockPlayer({required int playerId, int? textureId}) {
|
||||
final pluginApi = MockAndroidVideoPlayerApi();
|
||||
final instanceApi = MockVideoPlayerInstanceApi();
|
||||
final player = AndroidVideoPlayer(
|
||||
pluginApi: pluginApi,
|
||||
playerApiProvider: (_) => instanceApi,
|
||||
);
|
||||
player.ensurePlayerInitialized(
|
||||
playerId,
|
||||
textureId == null
|
||||
? const VideoPlayerPlatformViewState()
|
||||
: VideoPlayerTextureViewState(textureId: textureId),
|
||||
);
|
||||
return (player, pluginApi, instanceApi);
|
||||
}
|
||||
|
||||
(
|
||||
AndroidVideoPlayer,
|
||||
MockAndroidVideoPlayerApi,
|
||||
MockVideoPlayerInstanceApi,
|
||||
StreamController<PlatformVideoEvent>,
|
||||
)
|
||||
setUpMockPlayerWithStream({required int playerId, int? textureId}) {
|
||||
final pluginApi = MockAndroidVideoPlayerApi();
|
||||
final instanceApi = MockVideoPlayerInstanceApi();
|
||||
final streamController = StreamController<PlatformVideoEvent>();
|
||||
final player = AndroidVideoPlayer(
|
||||
pluginApi: pluginApi,
|
||||
playerApiProvider: (_) => instanceApi,
|
||||
videoEventStreamProvider: (_) =>
|
||||
streamController.stream.asBroadcastStream(),
|
||||
);
|
||||
player.ensurePlayerInitialized(
|
||||
playerId,
|
||||
textureId == null
|
||||
? const VideoPlayerPlatformViewState()
|
||||
: VideoPlayerTextureViewState(textureId: textureId),
|
||||
);
|
||||
return (player, pluginApi, instanceApi, streamController);
|
||||
}
|
||||
|
||||
test('registration', () async {
|
||||
AndroidVideoPlayer.registerWith();
|
||||
expect(VideoPlayerPlatform.instance, isA<AndroidVideoPlayer>());
|
||||
});
|
||||
|
||||
group('AndroidVideoPlayer', () {
|
||||
test('init', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
await player.init();
|
||||
|
||||
verify(api.initialize());
|
||||
});
|
||||
|
||||
test('dispose', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
await player.dispose(1);
|
||||
|
||||
verify(api.dispose(1));
|
||||
});
|
||||
|
||||
test('create with asset', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const asset = 'someAsset';
|
||||
const package = 'somePackage';
|
||||
const assetKey = 'resultingAssetKey';
|
||||
when(
|
||||
api.getLookupKeyForAsset(asset, package),
|
||||
).thenAnswer((_) async => assetKey);
|
||||
|
||||
final int? playerId = await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.asset,
|
||||
asset: asset,
|
||||
package: package,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, 'asset:///$assetKey');
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('create with network', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const uri = 'https://example.com';
|
||||
final int? playerId = await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: uri,
|
||||
formatHint: VideoFormat.dash,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, uri);
|
||||
expect(creationOptions.formatHint, PlatformVideoFormat.dash);
|
||||
expect(creationOptions.httpHeaders, <String, String>{});
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('create with network passes headers', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
const headers = <String, String>{'Authorization': 'Bearer token'};
|
||||
await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: 'https://example.com',
|
||||
httpHeaders: headers,
|
||||
),
|
||||
);
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.httpHeaders, headers);
|
||||
});
|
||||
|
||||
test('create with network sets a default user agent', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: 'https://example.com',
|
||||
httpHeaders: <String, String>{},
|
||||
),
|
||||
);
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.userAgent, 'ExoPlayer');
|
||||
});
|
||||
|
||||
test('create with network uses user agent from headers', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
const userAgent = 'Test User Agent';
|
||||
const headers = <String, String>{'User-Agent': userAgent};
|
||||
await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: 'https://example.com',
|
||||
httpHeaders: headers,
|
||||
),
|
||||
);
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.userAgent, userAgent);
|
||||
});
|
||||
|
||||
test('create with file', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
const fileUri = 'file:///foo/bar';
|
||||
final int? playerId = await player.create(
|
||||
DataSource(sourceType: DataSourceType.file, uri: fileUri),
|
||||
);
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, fileUri);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('create with file passes headers', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
const fileUri = 'file:///foo/bar';
|
||||
const headers = <String, String>{'Authorization': 'Bearer token'};
|
||||
await player.create(
|
||||
DataSource(
|
||||
sourceType: DataSourceType.file,
|
||||
uri: fileUri,
|
||||
httpHeaders: headers,
|
||||
),
|
||||
);
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.httpHeaders, headers);
|
||||
});
|
||||
|
||||
test('createWithOptions with asset', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const asset = 'someAsset';
|
||||
const package = 'somePackage';
|
||||
const assetKey = 'resultingAssetKey';
|
||||
when(
|
||||
api.getLookupKeyForAsset(asset, package),
|
||||
).thenAnswer((_) async => assetKey);
|
||||
|
||||
final int? playerId = await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(
|
||||
sourceType: DataSourceType.asset,
|
||||
asset: asset,
|
||||
package: package,
|
||||
),
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, 'asset:///$assetKey');
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('createWithOptions with network', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const uri = 'https://example.com';
|
||||
final int? playerId = await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: uri,
|
||||
formatHint: VideoFormat.dash,
|
||||
),
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, uri);
|
||||
expect(creationOptions.formatHint, PlatformVideoFormat.dash);
|
||||
expect(creationOptions.httpHeaders, <String, String>{});
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('createWithOptions with network passes headers', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const headers = <String, String>{'Authorization': 'Bearer token'};
|
||||
final int? playerId = await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(
|
||||
sourceType: DataSourceType.network,
|
||||
uri: 'https://example.com',
|
||||
httpHeaders: headers,
|
||||
),
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.httpHeaders, headers);
|
||||
expect(playerId, newPlayerId);
|
||||
});
|
||||
|
||||
test('createWithOptions with file', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForTextureView(any)).thenAnswer(
|
||||
(_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100),
|
||||
);
|
||||
|
||||
const fileUri = 'file:///foo/bar';
|
||||
final int? playerId = await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(sourceType: DataSourceType.file, uri: fileUri),
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, fileUri);
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<Texture>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('createWithOptions with file passes headers', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1, textureId: 100);
|
||||
when(
|
||||
api.createForTextureView(any),
|
||||
).thenAnswer((_) async => TexturePlayerIds(playerId: 2, textureId: 100));
|
||||
|
||||
const fileUri = 'file:///foo/bar';
|
||||
const headers = <String, String>{'Authorization': 'Bearer token'};
|
||||
await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(
|
||||
sourceType: DataSourceType.file,
|
||||
uri: fileUri,
|
||||
httpHeaders: headers,
|
||||
),
|
||||
viewType: VideoViewType.textureView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForTextureView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.httpHeaders, headers);
|
||||
});
|
||||
|
||||
test('createWithOptions with platform view', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
const newPlayerId = 2;
|
||||
when(api.createForPlatformView(any)).thenAnswer((_) async => newPlayerId);
|
||||
|
||||
const uri = 'file:///foo/bar';
|
||||
final int? playerId = await player.createWithOptions(
|
||||
VideoCreationOptions(
|
||||
dataSource: DataSource(sourceType: DataSourceType.file, uri: uri),
|
||||
viewType: VideoViewType.platformView,
|
||||
),
|
||||
);
|
||||
|
||||
final VerificationResult verification = verify(
|
||||
api.createForPlatformView(captureAny),
|
||||
);
|
||||
final creationOptions = verification.captured[0] as CreationOptions;
|
||||
expect(creationOptions.uri, uri);
|
||||
expect(playerId, newPlayerId);
|
||||
expect(
|
||||
player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)),
|
||||
isA<PlatformViewPlayer>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('setLooping', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
await player.setLooping(1, true);
|
||||
|
||||
verify(playerApi.setLooping(true));
|
||||
});
|
||||
|
||||
test('play', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
await player.play(1);
|
||||
|
||||
verify(playerApi.play());
|
||||
});
|
||||
|
||||
test('pause', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
await player.pause(1);
|
||||
|
||||
verify(playerApi.pause());
|
||||
});
|
||||
|
||||
group('setMixWithOthers', () {
|
||||
test('passes true', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
await player.setMixWithOthers(true);
|
||||
|
||||
verify(api.setMixWithOthers(true));
|
||||
});
|
||||
|
||||
test('passes false', () async {
|
||||
final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
await player.setMixWithOthers(false);
|
||||
|
||||
verify(api.setMixWithOthers(false));
|
||||
});
|
||||
});
|
||||
|
||||
test('setVolume', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
const volume = 0.7;
|
||||
await player.setVolume(1, volume);
|
||||
|
||||
verify(playerApi.setVolume(volume));
|
||||
});
|
||||
|
||||
test('setPlaybackSpeed', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
const speed = 1.5;
|
||||
await player.setPlaybackSpeed(1, speed);
|
||||
|
||||
verify(playerApi.setPlaybackSpeed(speed));
|
||||
});
|
||||
|
||||
test('seekTo', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
const positionMilliseconds = 12345;
|
||||
await player.seekTo(
|
||||
1,
|
||||
const Duration(milliseconds: positionMilliseconds),
|
||||
);
|
||||
|
||||
verify(playerApi.seekTo(positionMilliseconds));
|
||||
});
|
||||
|
||||
test('getPosition', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi playerApi,
|
||||
) = setUpMockPlayer(
|
||||
playerId: 1,
|
||||
);
|
||||
const positionMilliseconds = 12345;
|
||||
when(
|
||||
playerApi.getCurrentPosition(),
|
||||
).thenAnswer((_) async => positionMilliseconds);
|
||||
|
||||
final Duration position = await player.getPosition(1);
|
||||
expect(position, const Duration(milliseconds: positionMilliseconds));
|
||||
});
|
||||
|
||||
group('video events', () {
|
||||
// Sets up a mock player that emits the given event structure as a success
|
||||
// callback on the internal platform channel event stream, and returns
|
||||
// the player's videoEventsFor(...) stream.
|
||||
Stream<VideoEvent> mockPlayerEmitingEvents(
|
||||
List<PlatformVideoEvent> events,
|
||||
) {
|
||||
const playerId = 1;
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
_,
|
||||
StreamController<PlatformVideoEvent> streamController,
|
||||
) = setUpMockPlayerWithStream(
|
||||
playerId: playerId,
|
||||
);
|
||||
|
||||
events.forEach(streamController.add);
|
||||
|
||||
return player.videoEventsFor(playerId);
|
||||
}
|
||||
|
||||
test('initialize', () async {
|
||||
final Stream<VideoEvent> eventStream =
|
||||
mockPlayerEmitingEvents(<PlatformVideoEvent>[
|
||||
InitializationEvent(
|
||||
duration: 98765,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotationCorrection: 90,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: const Duration(milliseconds: 98765),
|
||||
size: const Size(1920, 1080),
|
||||
rotationCorrection: 90,
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('initialization triggers buffer update polling', () async {
|
||||
final Stream<VideoEvent> eventStream =
|
||||
mockPlayerEmitingEvents(<PlatformVideoEvent>[
|
||||
InitializationEvent(
|
||||
duration: 98765,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotationCorrection: 90,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: const Duration(milliseconds: 98765),
|
||||
size: const Size(1920, 1080),
|
||||
rotationCorrection: 90,
|
||||
),
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration.zero),
|
||||
],
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('completed', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.ended),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(eventType: VideoEventType.completed),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('buffering start', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(eventType: VideoEventType.bufferingStart),
|
||||
// A buffer start should trigger a buffer update as well.
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration.zero),
|
||||
],
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('buffering end for ready', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[
|
||||
// Trigger a start first, since end is only emitted if it's
|
||||
// started.
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering),
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.ready),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
// Emitted by buffering.
|
||||
VideoEvent(eventType: VideoEventType.bufferingStart),
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration.zero),
|
||||
],
|
||||
),
|
||||
// Emitted by ready.
|
||||
VideoEvent(eventType: VideoEventType.bufferingEnd),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('buffering end for idle', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[
|
||||
// Trigger a start first, since end is only emitted if it's
|
||||
// started.
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering),
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.idle),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
// Emitted by buffering.
|
||||
VideoEvent(eventType: VideoEventType.bufferingStart),
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration.zero),
|
||||
],
|
||||
),
|
||||
// Emitted by ready.
|
||||
VideoEvent(eventType: VideoEventType.bufferingEnd),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('buffering end for ended', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[
|
||||
// Trigger a start first, since end is only emitted if it's
|
||||
// started.
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering),
|
||||
PlaybackStateChangeEvent(state: PlatformPlaybackState.ended),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
// Emitted by buffering.
|
||||
VideoEvent(eventType: VideoEventType.bufferingStart),
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.bufferingUpdate,
|
||||
buffered: <DurationRange>[
|
||||
DurationRange(Duration.zero, Duration.zero),
|
||||
],
|
||||
),
|
||||
// Emitted by ended.
|
||||
VideoEvent(eventType: VideoEventType.completed),
|
||||
VideoEvent(eventType: VideoEventType.bufferingEnd),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('playback start', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[IsPlayingStateEvent(isPlaying: true)],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.isPlayingStateUpdate,
|
||||
isPlaying: true,
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('playback stop', () async {
|
||||
final Stream<VideoEvent> eventStream = mockPlayerEmitingEvents(
|
||||
<PlatformVideoEvent>[IsPlayingStateEvent(isPlaying: false)],
|
||||
);
|
||||
|
||||
expect(
|
||||
eventStream,
|
||||
emitsInOrder(<dynamic>[
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.isPlayingStateUpdate,
|
||||
isPlaying: false,
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('audio tracks', () {
|
||||
test('isAudioTrackSupportAvailable returns true', () {
|
||||
final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1);
|
||||
|
||||
expect(player.isAudioTrackSupportAvailable(), true);
|
||||
});
|
||||
|
||||
test('getAudioTracks returns empty list when no tracks', () async {
|
||||
final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
when(api.getAudioTracks()).thenAnswer(
|
||||
(_) async => NativeAudioTrackData(
|
||||
exoPlayerTracks: <ExoPlayerAudioTrackData>[],
|
||||
),
|
||||
);
|
||||
|
||||
final List<VideoAudioTrack> tracks = await player.getAudioTracks(1);
|
||||
|
||||
expect(tracks, isEmpty);
|
||||
verify(api.getAudioTracks());
|
||||
});
|
||||
|
||||
test(
|
||||
'getAudioTracks converts native tracks to VideoAudioTrack',
|
||||
() async {
|
||||
final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
when(api.getAudioTracks()).thenAnswer(
|
||||
(_) async => NativeAudioTrackData(
|
||||
exoPlayerTracks: <ExoPlayerAudioTrackData>[
|
||||
ExoPlayerAudioTrackData(
|
||||
groupIndex: 0,
|
||||
trackIndex: 1,
|
||||
label: 'English',
|
||||
language: 'en',
|
||||
isSelected: true,
|
||||
bitrate: 128000,
|
||||
sampleRate: 44100,
|
||||
channelCount: 2,
|
||||
codec: 'mp4a.40.2',
|
||||
),
|
||||
ExoPlayerAudioTrackData(
|
||||
groupIndex: 0,
|
||||
trackIndex: 2,
|
||||
label: 'Spanish',
|
||||
language: 'es',
|
||||
isSelected: false,
|
||||
bitrate: 128000,
|
||||
sampleRate: 44100,
|
||||
channelCount: 2,
|
||||
codec: 'mp4a.40.2',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final List<VideoAudioTrack> tracks = await player.getAudioTracks(1);
|
||||
|
||||
expect(tracks.length, 2);
|
||||
|
||||
expect(tracks[0].id, '0_1');
|
||||
expect(tracks[0].label, 'English');
|
||||
expect(tracks[0].language, 'en');
|
||||
expect(tracks[0].isSelected, true);
|
||||
expect(tracks[0].bitrate, 128000);
|
||||
expect(tracks[0].sampleRate, 44100);
|
||||
expect(tracks[0].channelCount, 2);
|
||||
expect(tracks[0].codec, 'mp4a.40.2');
|
||||
|
||||
expect(tracks[1].id, '0_2');
|
||||
expect(tracks[1].label, 'Spanish');
|
||||
expect(tracks[1].language, 'es');
|
||||
expect(tracks[1].isSelected, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('getAudioTracks handles null exoPlayerTracks', () async {
|
||||
final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) =
|
||||
setUpMockPlayer(playerId: 1);
|
||||
when(
|
||||
api.getAudioTracks(),
|
||||
).thenAnswer((_) async => NativeAudioTrackData());
|
||||
|
||||
final List<VideoAudioTrack> tracks = await player.getAudioTracks(1);
|
||||
|
||||
expect(tracks, isEmpty);
|
||||
});
|
||||
|
||||
test('selectAudioTrack parses trackId and calls API', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi api,
|
||||
StreamController<PlatformVideoEvent> streamController,
|
||||
) = setUpMockPlayerWithStream(
|
||||
playerId: 1,
|
||||
);
|
||||
when(api.selectAudioTrack(2, 3)).thenAnswer((_) async {});
|
||||
|
||||
// Start the selection and immediately send the track changed event
|
||||
final Future<void> selectionFuture = player.selectAudioTrack(1, '2_3');
|
||||
streamController.add(AudioTrackChangedEvent());
|
||||
await selectionFuture;
|
||||
|
||||
verify(api.selectAudioTrack(2, 3));
|
||||
});
|
||||
|
||||
test('selectAudioTrack throws on invalid trackId format', () async {
|
||||
final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1);
|
||||
|
||||
expect(
|
||||
() => player.selectAudioTrack(1, 'invalid'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAudioTrack throws on trackId with too many parts', () async {
|
||||
final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1);
|
||||
|
||||
expect(
|
||||
() => player.selectAudioTrack(1, '1_2_3'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAudioTrack completes on AudioTrackChangedEvent', () async {
|
||||
final (
|
||||
AndroidVideoPlayer player,
|
||||
_,
|
||||
MockVideoPlayerInstanceApi api,
|
||||
StreamController<PlatformVideoEvent> streamController,
|
||||
) = setUpMockPlayerWithStream(
|
||||
playerId: 1,
|
||||
);
|
||||
when(api.selectAudioTrack(0, 1)).thenAnswer((_) async {});
|
||||
|
||||
// Start selection
|
||||
final Future<void> selectionFuture = player.selectAudioTrack(1, '0_1');
|
||||
|
||||
// Simulate the track changed event from ExoPlayer
|
||||
streamController.add(AudioTrackChangedEvent());
|
||||
|
||||
// Should complete without timeout
|
||||
await selectionFuture;
|
||||
|
||||
verify(api.selectAudioTrack(0, 1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Mocks generated by Mockito 5.4.6 from annotations
|
||||
// in video_player_android/test/android_video_player_test.dart.
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i3;
|
||||
import 'package:video_player_android/src/messages.g.dart' as _i2;
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: avoid_redundant_argument_values
|
||||
// ignore_for_file: avoid_setters_without_getters
|
||||
// ignore_for_file: comment_references
|
||||
// ignore_for_file: deprecated_member_use
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||
// ignore_for_file: must_be_immutable
|
||||
// ignore_for_file: prefer_const_constructors
|
||||
// ignore_for_file: unnecessary_parenthesis
|
||||
// ignore_for_file: camel_case_types
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
|
||||
class _FakeTexturePlayerIds_0 extends _i1.SmartFake
|
||||
implements _i2.TexturePlayerIds {
|
||||
_FakeTexturePlayerIds_0(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
class _FakeNativeAudioTrackData_1 extends _i1.SmartFake
|
||||
implements _i2.NativeAudioTrackData {
|
||||
_FakeNativeAudioTrackData_1(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
/// A class which mocks [AndroidVideoPlayerApi].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockAndroidVideoPlayerApi extends _i1.Mock
|
||||
implements _i2.AndroidVideoPlayerApi {
|
||||
@override
|
||||
String get pigeonVar_messageChannelSuffix =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
returnValue: _i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
),
|
||||
returnValueForMissingStub: _i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
),
|
||||
)
|
||||
as String);
|
||||
|
||||
@override
|
||||
_i4.Future<void> initialize() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#initialize, []),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> createForPlatformView(_i2.CreationOptions? options) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#createForPlatformView, [options]),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
returnValueForMissingStub: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.TexturePlayerIds> createForTextureView(
|
||||
_i2.CreationOptions? options,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#createForTextureView, [options]),
|
||||
returnValue: _i4.Future<_i2.TexturePlayerIds>.value(
|
||||
_FakeTexturePlayerIds_0(
|
||||
this,
|
||||
Invocation.method(#createForTextureView, [options]),
|
||||
),
|
||||
),
|
||||
returnValueForMissingStub: _i4.Future<_i2.TexturePlayerIds>.value(
|
||||
_FakeTexturePlayerIds_0(
|
||||
this,
|
||||
Invocation.method(#createForTextureView, [options]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.TexturePlayerIds>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> dispose(int? playerId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#dispose, [playerId]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setMixWithOthers(bool? mixWithOthers) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#setMixWithOthers, [mixWithOthers]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> getLookupKeyForAsset(String? asset, String? packageName) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getLookupKeyForAsset, [asset, packageName]),
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#getLookupKeyForAsset, [asset, packageName]),
|
||||
),
|
||||
),
|
||||
returnValueForMissingStub: _i4.Future<String>.value(
|
||||
_i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#getLookupKeyForAsset, [asset, packageName]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<String>);
|
||||
}
|
||||
|
||||
/// A class which mocks [VideoPlayerInstanceApi].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockVideoPlayerInstanceApi extends _i1.Mock
|
||||
implements _i2.VideoPlayerInstanceApi {
|
||||
@override
|
||||
String get pigeonVar_messageChannelSuffix =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
returnValue: _i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
),
|
||||
returnValueForMissingStub: _i3.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#pigeonVar_messageChannelSuffix),
|
||||
),
|
||||
)
|
||||
as String);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setLooping(bool? looping) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#setLooping, [looping]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setVolume(double? volume) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#setVolume, [volume]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setPlaybackSpeed(double? speed) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#setPlaybackSpeed, [speed]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> play() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#play, []),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> pause() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#pause, []),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> seekTo(int? position) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#seekTo, [position]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> getCurrentPosition() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getCurrentPosition, []),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
returnValueForMissingStub: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> getBufferedPosition() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBufferedPosition, []),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
returnValueForMissingStub: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.NativeAudioTrackData> getAudioTracks() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getAudioTracks, []),
|
||||
returnValue: _i4.Future<_i2.NativeAudioTrackData>.value(
|
||||
_FakeNativeAudioTrackData_1(
|
||||
this,
|
||||
Invocation.method(#getAudioTracks, []),
|
||||
),
|
||||
),
|
||||
returnValueForMissingStub:
|
||||
_i4.Future<_i2.NativeAudioTrackData>.value(
|
||||
_FakeNativeAudioTrackData_1(
|
||||
this,
|
||||
Invocation.method(#getAudioTracks, []),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.NativeAudioTrackData>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> selectAudioTrack(int? groupIndex, int? trackIndex) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#selectAudioTrack, [groupIndex, trackIndex]),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i4.Future<void>);
|
||||
}
|
||||
Reference in New Issue
Block a user