diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 62646408..3266c0a6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 4a08fb01..15aa1582 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -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 { // 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(), + ), + ); + }, + ), ], ), ), diff --git a/lib/features/v2/local_camera/data/lan_interface_selector.dart b/lib/features/v2/local_camera/data/lan_interface_selector.dart new file mode 100644 index 00000000..29bf3ca6 --- /dev/null +++ b/lib/features/v2/local_camera/data/lan_interface_selector.dart @@ -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> selectLanInterfaces({bool ethernetOnly = true}) async { + final raw = []; + 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; +} diff --git a/lib/features/v2/local_camera/data/onvif_client.dart b/lib/features/v2/local_camera/data/onvif_client.dart new file mode 100644 index 00000000..d9071174 --- /dev/null +++ b/lib/features/v2/local_camera/data/onvif_client.dart @@ -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.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.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 = [ + ...nonceBytes, + ...utf8.encode(created), + ...utf8.encode(password), + ]; + final passwordDigest = base64.encode(sha1.convert(digestInput).bytes); + + return ''' + + $username + $passwordDigest + $nonceB64 + $created + + '''; + } + + String _envelope({ + required String serviceNs, + required String actionTail, + required String bodyInner, + }) { + final action = '$serviceNs/$actionTail'; + return ''' + + + $action + ${_uuid()} + ${_wsseHeader()} + + $bodyInner +'''; + } + + Future _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: + '', + ); + final resp = await _post(xAddr, body); + final doc = XmlDocument.parse(resp); + return ( + manufacturer: _firstText(doc, 'Manufacturer'), + model: _firstText(doc, 'Model'), + ); + } + + /// 获取 Media 服务地址。 + Future getMediaXAddr(String deviceXAddr) async { + final body = _envelope( + serviceNs: 'http://www.onvif.org/ver10/device/wsdl', + actionTail: 'GetCapabilities', + bodyInner: + 'Media', + ); + 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: + '', + ); + 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 getFirstProfileToken(String mediaXAddr) async { + final body = _envelope( + serviceNs: 'http://www.onvif.org/ver10/media/wsdl', + actionTail: 'GetProfiles', + bodyInner: + '', + ); + 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 getLowLatencyProfileToken(String mediaXAddr) async { + final body = _envelope( + serviceNs: 'http://www.onvif.org/ver10/media/wsdl', + actionTail: 'GetProfiles', + bodyInner: + '', + ); + 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 getStreamUri(String mediaXAddr, String profileToken) async { + final body = _envelope( + serviceNs: 'http://www.onvif.org/ver10/media/wsdl', + actionTail: 'GetStreamUri', + bodyInner: + ''' + RTP-Unicast + RTSP + $profileToken + ''', + ); + 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 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; +} diff --git a/lib/features/v2/local_camera/data/onvif_discovery_service.dart b/lib/features/v2/local_camera/data/onvif_discovery_service.dart new file mode 100644 index 00000000..7ec1c71f --- /dev/null +++ b/lib/features/v2/local_camera/data/onvif_discovery_service.dart @@ -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> discover({ + Duration timeout = const Duration(seconds: 8), + }) async { + final found = {}; + final completer = Completer>(); + 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 openSocket(InternetAddress local) async { + for (final port in [_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 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.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 ''' + + + http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe + ${_uuid()} + urn:schemas-xmlsoap-org:ws:2005:04:discovery + dn:NetworkVideoTransmitter + + +'''; + } + + 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); +} diff --git a/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart b/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart new file mode 100644 index 00000000..e638e268 --- /dev/null +++ b/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart @@ -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 _candidatePorts = [ + 80, + 8000, + 8080, + 880, + 8081, + 8899, + 81, + 808, + ]; + + /// 常见 device_service 路径。 + static const List _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> scan() async { + final localIps = {}; + final prefixes = {}; + + 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 = []; + 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 = {}; + 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 _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 _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 _runPool( + List items, + int max, + Future Function(T) worker, + ) async { + var index = 0; + Future runner() async { + while (true) { + final i = index++; + if (i >= items.length) return; + await worker(items[i]); + } + } + + final workers = >[]; + 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; + } +} diff --git a/lib/features/v2/local_camera/data/rtsp_url_guesser.dart b/lib/features/v2/local_camera/data/rtsp_url_guesser.dart new file mode 100644 index 00000000..3785d994 --- /dev/null +++ b/lib/features/v2/local_camera/data/rtsp_url_guesser.dart @@ -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 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 [ + // === 雄迈/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, + ]; + } +} diff --git a/lib/features/v2/local_camera/domain/entities/onvif_camera.dart b/lib/features/v2/local_camera/domain/entities/onvif_camera.dart new file mode 100644 index 00000000..8c352b6f --- /dev/null +++ b/lib/features/v2/local_camera/domain/entities/onvif_camera.dart @@ -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)'; +} diff --git a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart new file mode 100644 index 00000000..214af95d --- /dev/null +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart @@ -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 { + 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 start() async { + _listenConnectivity(); + await _checkNetworkAndDiscover(); + } + + /// 链条:①网络检查 ②ONVIF发现 ③取流(GetStreamUri) ④拉流播放 + Future _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 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 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? 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 _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.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 _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 reconnect() async { + final cam = state.selected; + if (cam == null) { + await _checkNetworkAndDiscover(); + return; + } + await selectCamera(cam); + } + + /// 返回摄像头列表(重新搜索)。 + Future 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 _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 _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 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 results) { + // 盯以太网掉线(拔掉以太网线 / 共享网络断开),而非任意网络 + final ethernetLost = !results.contains(ConnectivityResult.ethernet); + if (ethernetLost && + (state.isPlaying || state.phase == LocalCameraPhase.connecting)) { + _handleDisconnected(); + } + }, + ); + } + + /// 快速校验上次缓存的候选:短超时逐个探活,任一 SOAP 有回包即保留。 + /// 全部不可达则返回空,交由上层做满段扫描兜底。 + Future> _validateCached(List 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().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 _cacheCameras(List 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> 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>(); + 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 close() async { + _userStopped = true; + _openWatchdog?.cancel(); + _stallTimer?.cancel(); + _connectivitySub?.cancel(); + await _disposeController(); + return super.close(); + } +} diff --git a/lib/features/v2/local_camera/presentation/cubit/local_camera_state.dart b/lib/features/v2/local_camera/presentation/cubit/local_camera_state.dart new file mode 100644 index 00000000..9c3eaef6 --- /dev/null +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_state.dart @@ -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 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? 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 get props => [ + phase, + networkStep, + discoveryStep, + streamStep, + playStep, + cameras, + selected, + rtspUrl, + message, + showReconnect, + ]; +} diff --git a/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart b/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart new file mode 100644 index 00000000..76cce3be --- /dev/null +++ b/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart @@ -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(); + + return BlocConsumer( + 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().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), + ], + ), + ), + ), + ); + } +} diff --git a/packages/video_player_android/android/src/main/AndroidManifest.xml b/packages/video_player_android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e6e98bfe --- /dev/null +++ b/packages/video_player_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/video_player_android/example/android/app/src/main/AndroidManifest.xml b/packages/video_player_android/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..dd84f58c --- /dev/null +++ b/packages/video_player_android/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/packages/video_player_android/example/integration_test/video_player_android_test.dart b/packages/video_player_android/example/integration_test/video_player_android_test.dart new file mode 100644 index 00000000..e60410ad --- /dev/null +++ b/packages/video_player_android/example/integration_test/video_player_android_test.dart @@ -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 {}); +} diff --git a/packages/video_player_android/example/integration_test/video_player_test.dart b/packages/video_player_android/example/integration_test/video_player_test.dart new file mode 100644 index 00000000..e9d10764 --- /dev/null +++ b/packages/video_player_android/example/integration_test/video_player_test.dart @@ -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()); + }); + + 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 _getDuration(AndroidVideoPlayer player, int playerId) { + return player + .videoEventsFor(playerId) + .firstWhere((VideoEvent event) { + return event.eventType == VideoEventType.initialized; + }) + .then((VideoEvent event) { + return event.duration!; + }); +} + +Future _getBufferingRange( + AndroidVideoPlayer player, + int playerId, +) { + return player + .videoEventsFor(playerId) + .firstWhere((VideoEvent event) { + return event.eventType == VideoEventType.bufferingUpdate; + }) + .then((VideoEvent event) { + return event.buffered!.first; + }); +} diff --git a/packages/video_player_android/example/lib/main.dart b/packages/video_player_android/example/lib/main.dart new file mode 100644 index 00000000..cf8f341e --- /dev/null +++ b/packages/video_player_android/example/lib/main.dart @@ -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('home_page'), + appBar: AppBar( + title: const Text('Video player example'), + bottom: const TabBar( + isScrollable: true, + tabs: [ + 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: [ + _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: [ + TabBar( + controller: _tabController, + isScrollable: true, + tabs: const [ + Tab(icon: Icon(Icons.texture), text: 'Texture view'), + Tab(icon: Icon(Icons.construction), text: 'Platform view'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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 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: [ + 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: [ + VideoPlayer(_controller!), + _ControlsOverlay(controller: _controller!), + VideoProgressIndicator(_controller!), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _ControlsOverlay extends StatelessWidget { + const _ControlsOverlay({required this.controller}); + + static const List _examplePlaybackRates = [ + 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: [ + 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('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( + initialValue: controller.value.playbackSpeed, + tooltip: 'Playback speed', + onSelected: (double speed) { + controller.setPlaybackSpeed(speed); + }, + itemBuilder: (BuildContext context) { + return >[ + for (final double speed in _examplePlaybackRates) + PopupMenuItem(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'), + ), + ), + ), + ], + ); + } +} diff --git a/packages/video_player_android/example/lib/mini_controller.dart b/packages/video_player_android/example/lib/mini_controller.dart new file mode 100644 index 00000000..d8b275df --- /dev/null +++ b/packages/video_player_android/example/lib/mini_controller.dart @@ -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 [], + 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 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? 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 { + /// 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? _creatingCompleter; + StreamSubscription? _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 initialize() async { + _creatingCompleter = Completer(); + + 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 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 dispose() async { + if (_creatingCompleter != null) { + await _creatingCompleter!.future; + _timer?.cancel(); + await _eventSubscription?.cancel(); + await _platform.dispose(_playerId); + } + super.dispose(); + } + + /// Starts playing the video. + Future play() async { + value = value.copyWith(isPlaying: true); + await _applyPlayPause(); + } + + /// Pauses the video. + Future pause() async { + value = value.copyWith(isPlaying: false); + await _applyPlayPause(); + } + + Future _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 _applyPlaybackSpeed() async { + if (value.isPlaying) { + await _platform.setPlaybackSpeed(_playerId, value.playbackSpeed); + } + } + + /// The position in the current video. + Future get position async { + return _platform.getPosition(_playerId); + } + + /// Sets the video's current timestamp to be at [position]. + Future 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 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 createState() => _VideoPlayerState(); +} + +class _VideoPlayerState extends State { + _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 createState() => _VideoProgressIndicatorState(); +} + +class _VideoProgressIndicatorState extends State { + _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: [ + LinearProgressIndicator( + value: maxBuffering, + valueColor: const AlwaysStoppedAnimation(bufferedColor), + backgroundColor: backgroundColor, + ), + LinearProgressIndicator( + value: duration == 0.0 ? 0.0 : position / duration, + valueColor: const AlwaysStoppedAnimation(playedColor), + backgroundColor: Colors.transparent, + ), + ], + ); + } else { + progressIndicator = const LinearProgressIndicator( + valueColor: AlwaysStoppedAnimation(playedColor), + backgroundColor: backgroundColor, + ); + } + return _VideoScrubber( + controller: controller, + child: Padding( + padding: const EdgeInsets.only(top: 5.0), + child: progressIndicator, + ), + ); + } +} diff --git a/packages/video_player_android/example/test_driver/integration_test.dart b/packages/video_player_android/example/test_driver/integration_test.dart new file mode 100644 index 00000000..fb3dec00 --- /dev/null +++ b/packages/video_player_android/example/test_driver/integration_test.dart @@ -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 main() => integrationDriver(); diff --git a/packages/video_player_android/lib/src/android_video_player.dart b/packages/video_player_android/lib/src/android_video_player.dart new file mode 100644 index 00000000..84249bd4 --- /dev/null +++ b/packages/video_player_android/lib/src/android_video_player.dart @@ -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 _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 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 Function(String streamIdentifier) + _videoEventStreamProvider; + + final Map _players = {}; + + /// Registers this class as the default instance of [PathProviderPlatform]. + static void registerWith() { + VideoPlayerPlatform.instance = AndroidVideoPlayer(); + } + + @override + Future init() { + return _api.initialize(); + } + + @override + Future dispose(int playerId) async { + final _PlayerInstance? player = _players.remove(playerId); + await player?.dispose(); + await _api.dispose(playerId); + } + + @override + Future create(DataSource dataSource) { + return createWithOptions( + VideoCreationOptions( + dataSource: dataSource, + // Compatibility; "create" is always a textureView (createWithOptions + // allows selecting). + viewType: VideoViewType.textureView, + ), + ); + } + + @override + Future createWithOptions(VideoCreationOptions options) async { + final DataSource dataSource = options.dataSource; + + String? uri; + PlatformVideoFormat? formatHint; + final Map 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 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 setLooping(int playerId, bool looping) { + return _playerWith(id: playerId).setLooping(looping); + } + + @override + Future play(int playerId) { + return _playerWith(id: playerId).play(); + } + + @override + Future pause(int playerId) { + return _playerWith(id: playerId).pause(); + } + + @override + Future setVolume(int playerId, double volume) { + return _playerWith(id: playerId).setVolume(volume); + } + + @override + Future setPlaybackSpeed(int playerId, double speed) { + assert(speed > 0); + + return _playerWith(id: playerId).setPlaybackSpeed(speed); + } + + @override + Future seekTo(int playerId, Duration position) { + return _playerWith(id: playerId).seekTo(position); + } + + @override + Future getPosition(int playerId) async { + return _playerWith(id: playerId).getPosition(); + } + + @override + Stream 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 setMixWithOthers(bool mixWithOthers) { + return _api.setMixWithOthers(mixWithOthers); + } + + @override + Future> getAudioTracks(int playerId) async { + final NativeAudioTrackData nativeData = await _playerWith( + id: playerId, + ).getAudioTracks(); + final tracks = []; + + // 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 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 videoEventStream, + }) { + _eventSubscription = videoEventStream.listen( + _onStreamEvent, + onError: (Object e) { + _setBuffering(false); + _eventStreamController.addError(e); + }, + ); + } + + final VideoPlayerInstanceApi _api; + final StreamController _eventStreamController = + StreamController(); + late final StreamSubscription _eventSubscription; + bool _isDisposed = false; + Timer? _bufferPollingTimer; + int _lastBufferPosition = -1; + bool _isBuffering = false; + Completer? _audioTrackSelectionCompleter; + + final VideoPlayerViewState viewState; + + Future setLooping(bool looping) { + return _api.setLooping(looping); + } + + Future play() { + return _api.play(); + } + + Future pause() { + return _api.pause(); + } + + Future setVolume(double volume) { + return _api.setVolume(volume); + } + + Future setPlaybackSpeed(double speed) { + return _api.setPlaybackSpeed(speed); + } + + Future seekTo(Duration position) { + return _api.seekTo(position.inMilliseconds); + } + + Future getPosition() async { + return Duration(milliseconds: await _api.getCurrentPosition()); + } + + Stream videoEvents() { + return _eventStreamController.stream; + } + + Future getAudioTracks() { + return _api.getAudioTracks(); + } + + Future selectAudioTrack(String trackId) async { + // Parse the trackId to get groupIndex and trackIndex + final List 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(); + + 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 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 _bufferRangeForPosition(int milliseconds) { + return [ + 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(); +} diff --git a/packages/video_player_android/lib/src/messages.g.dart b/packages/video_player_android/lib/src/messages.g.dart new file mode 100644 index 00000000..1aca7dc5 --- /dev/null +++ b/packages/video_player_android/lib/src/messages.g.dart @@ -0,0 +1,1158 @@ +// 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. +// Autogenerated from Pigeon (v26.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed.every( + ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]), + ); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +/// 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 { + InitializationEvent({ + required this.duration, + required this.width, + required this.height, + required this.rotationCorrection, + }); + + /// The video duration in milliseconds. + int duration; + + /// The width of the video in pixels. + int width; + + /// The height of the video in pixels. + int height; + + /// The rotation that should be applied during playback. + int rotationCorrection; + + List _toList() { + return [duration, width, height, rotationCorrection]; + } + + Object encode() { + return _toList(); + } + + static InitializationEvent decode(Object result) { + result as List; + return InitializationEvent( + duration: result[0]! as int, + width: result[1]! as int, + height: result[2]! as int, + rotationCorrection: result[3]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! InitializationEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Sent when the video state changes. +/// +/// Corresponds to ExoPlayer's onPlaybackStateChanged. +class PlaybackStateChangeEvent extends PlatformVideoEvent { + PlaybackStateChangeEvent({required this.state}); + + PlatformPlaybackState state; + + List _toList() { + return [state]; + } + + Object encode() { + return _toList(); + } + + static PlaybackStateChangeEvent decode(Object result) { + result as List; + return PlaybackStateChangeEvent(state: result[0]! as PlatformPlaybackState); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlaybackStateChangeEvent || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Sent when the video starts or stops playing. +/// +/// Corresponds to ExoPlayer's onIsPlayingChanged. +class IsPlayingStateEvent extends PlatformVideoEvent { + IsPlayingStateEvent({required this.isPlaying}); + + bool isPlaying; + + List _toList() { + return [isPlaying]; + } + + Object encode() { + return _toList(); + } + + static IsPlayingStateEvent decode(Object result) { + result as List; + return IsPlayingStateEvent(isPlaying: result[0]! as bool); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IsPlayingStateEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// 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 { + AudioTrackChangedEvent({this.selectedTrackId}); + + /// The ID of the newly selected audio track, if any. + String? selectedTrackId; + + List _toList() { + return [selectedTrackId]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackChangedEvent decode(Object result) { + result as List; + return AudioTrackChangedEvent(selectedTrackId: result[0] as String?); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackChangedEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Information passed to the platform view creation. +class PlatformVideoViewCreationParams { + PlatformVideoViewCreationParams({required this.playerId}); + + int playerId; + + List _toList() { + return [playerId]; + } + + Object encode() { + return _toList(); + } + + static PlatformVideoViewCreationParams decode(Object result) { + result as List; + return PlatformVideoViewCreationParams(playerId: result[0]! as int); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlatformVideoViewCreationParams || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class CreationOptions { + CreationOptions({ + required this.uri, + this.formatHint, + required this.httpHeaders, + this.userAgent, + }); + + String uri; + + PlatformVideoFormat? formatHint; + + Map httpHeaders; + + String? userAgent; + + List _toList() { + return [uri, formatHint, httpHeaders, userAgent]; + } + + Object encode() { + return _toList(); + } + + static CreationOptions decode(Object result) { + result as List; + return CreationOptions( + uri: result[0]! as String, + formatHint: result[1] as PlatformVideoFormat?, + httpHeaders: (result[2] as Map?)! + .cast(), + userAgent: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! CreationOptions || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class TexturePlayerIds { + TexturePlayerIds({required this.playerId, required this.textureId}); + + int playerId; + + int textureId; + + List _toList() { + return [playerId, textureId]; + } + + Object encode() { + return _toList(); + } + + static TexturePlayerIds decode(Object result) { + result as List; + return TexturePlayerIds( + playerId: result[0]! as int, + textureId: result[1]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! TexturePlayerIds || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class PlaybackState { + PlaybackState({required this.playPosition, required this.bufferPosition}); + + /// The current playback position, in milliseconds. + int playPosition; + + /// The current buffer position, in milliseconds. + int bufferPosition; + + List _toList() { + return [playPosition, bufferPosition]; + } + + Object encode() { + return _toList(); + } + + static PlaybackState decode(Object result) { + result as List; + return PlaybackState( + playPosition: result[0]! as int, + bufferPosition: result[1]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlaybackState || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// 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; + + List _toList() { + return [ + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackMessage decode(Object result) { + result as List; + return AudioTrackMessage( + id: result[0]! as String, + label: result[1]! as String, + language: result[2]! as String, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + sampleRate: result[5] as int?, + channelCount: result[6] as int?, + codec: result[7] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// 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; + + List _toList() { + return [ + groupIndex, + trackIndex, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static ExoPlayerAudioTrackData decode(Object result) { + result as List; + return ExoPlayerAudioTrackData( + groupIndex: result[0]! as int, + trackIndex: result[1]! as int, + label: result[2] as String?, + language: result[3] as String?, + isSelected: result[4]! as bool, + bitrate: result[5] as int?, + sampleRate: result[6] as int?, + channelCount: result[7] as int?, + codec: result[8] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ExoPlayerAudioTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Container for raw audio track data from Android ExoPlayer. +class NativeAudioTrackData { + NativeAudioTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; + + List _toList() { + return [exoPlayerTracks]; + } + + Object encode() { + return _toList(); + } + + static NativeAudioTrackData decode(Object result) { + result as List; + return NativeAudioTrackData( + exoPlayerTracks: (result[0] as List?) + ?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeAudioTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is PlatformVideoFormat) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is PlatformPlaybackState) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is InitializationEvent) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is PlaybackStateChangeEvent) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is IsPlayingStateEvent) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is AudioTrackChangedEvent) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is PlatformVideoViewCreationParams) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is CreationOptions) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is TexturePlayerIds) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else if (value is PlaybackState) { + buffer.putUint8(138); + writeValue(buffer, value.encode()); + } else if (value is AudioTrackMessage) { + buffer.putUint8(139); + writeValue(buffer, value.encode()); + } else if (value is ExoPlayerAudioTrackData) { + buffer.putUint8(140); + writeValue(buffer, value.encode()); + } else if (value is NativeAudioTrackData) { + buffer.putUint8(141); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : PlatformVideoFormat.values[value]; + case 130: + final value = readValue(buffer) as int?; + return value == null ? null : PlatformPlaybackState.values[value]; + case 131: + return InitializationEvent.decode(readValue(buffer)!); + case 132: + return PlaybackStateChangeEvent.decode(readValue(buffer)!); + case 133: + return IsPlayingStateEvent.decode(readValue(buffer)!); + case 134: + return AudioTrackChangedEvent.decode(readValue(buffer)!); + case 135: + return PlatformVideoViewCreationParams.decode(readValue(buffer)!); + case 136: + return CreationOptions.decode(readValue(buffer)!); + case 137: + return TexturePlayerIds.decode(readValue(buffer)!); + case 138: + return PlaybackState.decode(readValue(buffer)!); + case 139: + return AudioTrackMessage.decode(readValue(buffer)!); + case 140: + return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + case 141: + return NativeAudioTrackData.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec( + _PigeonCodec(), +); + +class AndroidVideoPlayerApi { + /// Constructor for [AndroidVideoPlayerApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + AndroidVideoPlayerApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future initialize() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.initialize$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future createForPlatformView(CreationOptions options) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.createForPlatformView$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [options], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + Future createForTextureView(CreationOptions options) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.createForTextureView$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [options], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as TexturePlayerIds?)!; + } + } + + Future dispose(int playerId) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.dispose$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [playerId], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future setMixWithOthers(bool mixWithOthers) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setMixWithOthers$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [mixWithOthers], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future getLookupKeyForAsset(String asset, String? packageName) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.getLookupKeyForAsset$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [asset, packageName], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } +} + +class VideoPlayerInstanceApi { + /// Constructor for [VideoPlayerInstanceApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + VideoPlayerInstanceApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Sets whether to automatically loop playback of the video. + Future setLooping(bool looping) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setLooping$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [looping], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Sets the volume, with 0.0 being muted and 1.0 being full volume. + Future setVolume(double volume) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setVolume$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [volume], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Sets the playback speed as a multiple of normal speed. + Future setPlaybackSpeed(double speed) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setPlaybackSpeed$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [speed], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Begins playback if the video is not currently playing. + Future play() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.play$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Pauses playback if the video is currently playing. + Future pause() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.pause$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Seeks to the given playback position, in milliseconds. + Future seekTo(int position) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.seekTo$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [position], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Returns the current playback position, in milliseconds. + Future getCurrentPosition() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getCurrentPosition$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + /// Returns the current buffer position, in milliseconds. + Future getBufferedPosition() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getBufferedPosition$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + /// Gets the available audio tracks for the video. + Future getAudioTracks() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as NativeAudioTrackData?)!; + } + } + + /// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] + Future selectAudioTrack(int groupIndex, int trackIndex) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectAudioTrack$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [groupIndex, trackIndex], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +Stream videoEvents({String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel videoEventsChannel = EventChannel( + 'dev.flutter.pigeon.video_player_android.VideoEventChannel.videoEvents$instanceName', + pigeonMethodCodec, + ); + return videoEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as PlatformVideoEvent; + }); +} diff --git a/packages/video_player_android/lib/src/platform_view_player.dart b/packages/video_player_android/lib/src/platform_view_player.dart new file mode 100644 index 00000000..d7c49781 --- /dev/null +++ b/packages/video_player_android/lib/src/platform_view_player.dart @@ -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 >{}, + 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(); + }, + ), + ); + } +} diff --git a/packages/video_player_android/lib/video_player_android.dart b/packages/video_player_android/lib/video_player_android.dart new file mode 100644 index 00000000..7a4dc520 --- /dev/null +++ b/packages/video_player_android/lib/video_player_android.dart @@ -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'; diff --git a/packages/video_player_android/pigeons/messages.dart b/packages/video_player_android/pigeons/messages.dart new file mode 100644 index 00000000..8666b074 --- /dev/null +++ b/packages/video_player_android/pigeons/messages.dart @@ -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 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? 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(); +} diff --git a/packages/video_player_android/test/android_video_player_test.dart b/packages/video_player_android/test/android_video_player_test.dart new file mode 100644 index 00000000..810a815f --- /dev/null +++ b/packages/video_player_android/test/android_video_player_test.dart @@ -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(), + MockSpec(), +]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Provide dummy values for audio track types + provideDummy( + NativeAudioTrackData(exoPlayerTracks: []), + ); + provideDummy>( + Future.value( + NativeAudioTrackData(exoPlayerTracks: []), + ), + ); + provideDummy>(Future.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, + ) + setUpMockPlayerWithStream({required int playerId, int? textureId}) { + final pluginApi = MockAndroidVideoPlayerApi(); + final instanceApi = MockVideoPlayerInstanceApi(); + final streamController = StreamController(); + 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()); + }); + + 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(), + ); + }); + + 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, {}); + expect(playerId, newPlayerId); + expect( + player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)), + isA(), + ); + }); + + 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 = {'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: {}, + ), + ); + 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 = {'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(), + ); + }); + + 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 = {'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(), + ); + }); + + 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, {}); + expect(playerId, newPlayerId); + expect( + player.buildViewWithOptions(VideoViewOptions(playerId: playerId!)), + isA(), + ); + }); + + 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 = {'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(), + ); + }); + + 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 = {'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(), + ); + }); + + 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 mockPlayerEmitingEvents( + List events, + ) { + const playerId = 1; + final ( + AndroidVideoPlayer player, + _, + _, + StreamController streamController, + ) = setUpMockPlayerWithStream( + playerId: playerId, + ); + + events.forEach(streamController.add); + + return player.videoEventsFor(playerId); + } + + test('initialize', () async { + final Stream eventStream = + mockPlayerEmitingEvents([ + InitializationEvent( + duration: 98765, + width: 1920, + height: 1080, + rotationCorrection: 90, + ), + ]); + + expect( + eventStream, + emitsInOrder([ + 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 eventStream = + mockPlayerEmitingEvents([ + InitializationEvent( + duration: 98765, + width: 1920, + height: 1080, + rotationCorrection: 90, + ), + ]); + + expect( + eventStream, + emitsInOrder([ + VideoEvent( + eventType: VideoEventType.initialized, + duration: const Duration(milliseconds: 98765), + size: const Size(1920, 1080), + rotationCorrection: 90, + ), + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [ + DurationRange(Duration.zero, Duration.zero), + ], + ), + ]), + ); + }); + + test('completed', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [ + PlaybackStateChangeEvent(state: PlatformPlaybackState.ended), + ], + ); + + expect( + eventStream, + emitsInOrder([ + VideoEvent(eventType: VideoEventType.completed), + ]), + ); + }); + + test('buffering start', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [ + PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering), + ], + ); + + expect( + eventStream, + emitsInOrder([ + VideoEvent(eventType: VideoEventType.bufferingStart), + // A buffer start should trigger a buffer update as well. + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [ + DurationRange(Duration.zero, Duration.zero), + ], + ), + ]), + ); + }); + + test('buffering end for ready', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [ + // Trigger a start first, since end is only emitted if it's + // started. + PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering), + PlaybackStateChangeEvent(state: PlatformPlaybackState.ready), + ], + ); + + expect( + eventStream, + emitsInOrder([ + // Emitted by buffering. + VideoEvent(eventType: VideoEventType.bufferingStart), + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [ + DurationRange(Duration.zero, Duration.zero), + ], + ), + // Emitted by ready. + VideoEvent(eventType: VideoEventType.bufferingEnd), + ]), + ); + }); + + test('buffering end for idle', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [ + // Trigger a start first, since end is only emitted if it's + // started. + PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering), + PlaybackStateChangeEvent(state: PlatformPlaybackState.idle), + ], + ); + + expect( + eventStream, + emitsInOrder([ + // Emitted by buffering. + VideoEvent(eventType: VideoEventType.bufferingStart), + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [ + DurationRange(Duration.zero, Duration.zero), + ], + ), + // Emitted by ready. + VideoEvent(eventType: VideoEventType.bufferingEnd), + ]), + ); + }); + + test('buffering end for ended', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [ + // Trigger a start first, since end is only emitted if it's + // started. + PlaybackStateChangeEvent(state: PlatformPlaybackState.buffering), + PlaybackStateChangeEvent(state: PlatformPlaybackState.ended), + ], + ); + + expect( + eventStream, + emitsInOrder([ + // Emitted by buffering. + VideoEvent(eventType: VideoEventType.bufferingStart), + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [ + DurationRange(Duration.zero, Duration.zero), + ], + ), + // Emitted by ended. + VideoEvent(eventType: VideoEventType.completed), + VideoEvent(eventType: VideoEventType.bufferingEnd), + ]), + ); + }); + + test('playback start', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [IsPlayingStateEvent(isPlaying: true)], + ); + + expect( + eventStream, + emitsInOrder([ + VideoEvent( + eventType: VideoEventType.isPlayingStateUpdate, + isPlaying: true, + ), + ]), + ); + }); + + test('playback stop', () async { + final Stream eventStream = mockPlayerEmitingEvents( + [IsPlayingStateEvent(isPlaying: false)], + ); + + expect( + eventStream, + emitsInOrder([ + 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: [], + ), + ); + + final List 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( + 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 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 tracks = await player.getAudioTracks(1); + + expect(tracks, isEmpty); + }); + + test('selectAudioTrack parses trackId and calls API', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + StreamController streamController, + ) = setUpMockPlayerWithStream( + playerId: 1, + ); + when(api.selectAudioTrack(2, 3)).thenAnswer((_) async {}); + + // Start the selection and immediately send the track changed event + final Future 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()), + ); + }); + + 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()), + ); + }); + + test('selectAudioTrack completes on AudioTrackChangedEvent', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + StreamController streamController, + ) = setUpMockPlayerWithStream( + playerId: 1, + ); + when(api.selectAudioTrack(0, 1)).thenAnswer((_) async {}); + + // Start selection + final Future 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)); + }); + }); + }); +} diff --git a/packages/video_player_android/test/android_video_player_test.mocks.dart b/packages/video_player_android/test/android_video_player_test.mocks.dart new file mode 100644 index 00000000..212c9bde --- /dev/null +++ b/packages/video_player_android/test/android_video_player_test.mocks.dart @@ -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( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), + ), + returnValueForMissingStub: _i3.dummyValue( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), + ), + ) + as String); + + @override + _i4.Future initialize() => + (super.noSuchMethod( + Invocation.method(#initialize, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future createForPlatformView(_i2.CreationOptions? options) => + (super.noSuchMethod( + Invocation.method(#createForPlatformView, [options]), + returnValue: _i4.Future.value(0), + returnValueForMissingStub: _i4.Future.value(0), + ) + as _i4.Future); + + @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 dispose(int? playerId) => + (super.noSuchMethod( + Invocation.method(#dispose, [playerId]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setMixWithOthers(bool? mixWithOthers) => + (super.noSuchMethod( + Invocation.method(#setMixWithOthers, [mixWithOthers]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future getLookupKeyForAsset(String? asset, String? packageName) => + (super.noSuchMethod( + Invocation.method(#getLookupKeyForAsset, [asset, packageName]), + returnValue: _i4.Future.value( + _i3.dummyValue( + this, + Invocation.method(#getLookupKeyForAsset, [asset, packageName]), + ), + ), + returnValueForMissingStub: _i4.Future.value( + _i3.dummyValue( + this, + Invocation.method(#getLookupKeyForAsset, [asset, packageName]), + ), + ), + ) + as _i4.Future); +} + +/// 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( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), + ), + returnValueForMissingStub: _i3.dummyValue( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), + ), + ) + as String); + + @override + _i4.Future setLooping(bool? looping) => + (super.noSuchMethod( + Invocation.method(#setLooping, [looping]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setVolume(double? volume) => + (super.noSuchMethod( + Invocation.method(#setVolume, [volume]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setPlaybackSpeed(double? speed) => + (super.noSuchMethod( + Invocation.method(#setPlaybackSpeed, [speed]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future play() => + (super.noSuchMethod( + Invocation.method(#play, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future pause() => + (super.noSuchMethod( + Invocation.method(#pause, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future seekTo(int? position) => + (super.noSuchMethod( + Invocation.method(#seekTo, [position]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future getCurrentPosition() => + (super.noSuchMethod( + Invocation.method(#getCurrentPosition, []), + returnValue: _i4.Future.value(0), + returnValueForMissingStub: _i4.Future.value(0), + ) + as _i4.Future); + + @override + _i4.Future getBufferedPosition() => + (super.noSuchMethod( + Invocation.method(#getBufferedPosition, []), + returnValue: _i4.Future.value(0), + returnValueForMissingStub: _i4.Future.value(0), + ) + as _i4.Future); + + @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 selectAudioTrack(int? groupIndex, int? trackIndex) => + (super.noSuchMethod( + Invocation.method(#selectAudioTrack, [groupIndex, trackIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +}