本地视频延迟优化+视频的获取链路播放

This commit is contained in:
2026-10-13 12:40:16 +08:00
parent 9671798f29
commit e4f7cc7a90
25 changed files with 6438 additions and 0 deletions

View File

@@ -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"/>

View File

@@ -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(),
),
);
},
),
],
),
),

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

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

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

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

View 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,
];
}
}

View File

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

View File

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

View File

@@ -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,
];
}

View File

@@ -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),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.flutter.plugins.videoplayer">
</manifest>

View File

@@ -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>

View File

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

View File

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

View 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'),
),
),
),
],
);
}
}

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

View File

@@ -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();

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

File diff suppressed because it is too large Load Diff

View File

@@ -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();
},
),
);
}
}

View File

@@ -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';

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

View File

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

View File

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