diff --git a/.gitignore b/.gitignore index 78443a9f..e202311b 100644 --- a/.gitignore +++ b/.gitignore @@ -114,4 +114,23 @@ test_run.log Thumbs.db # 环境变量敏感信息 -.env* \ No newline at end of file +.env* + +# ---------------------------------------------------------------------------- +# 备份文件与一次性调试产物(不需要提交) +# ---------------------------------------------------------------------------- +*.bak +*.bak-* +pubspec.lock.bak* +fix_lock.py +git_lock_versions.json + +# ---------------------------------------------------------------------------- +# 临时文档 / 软著手册生成物料(不需要提交) +# ---------------------------------------------------------------------------- +temp_*.md +temp_*.txt +temp_*.py +temp_* +temp_docx_gen/ +*.docx \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d290213..b0653c0a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,10 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - packages/** + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/android/app/src/main/res/drawable-hdpi/launch_image.png b/android/app/src/main/res/drawable-hdpi/launch_image.png new file mode 100644 index 00000000..4c2dac7d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/launch_image.png differ diff --git a/android/app/src/main/res/drawable-mdpi/launch_image.png b/android/app/src/main/res/drawable-mdpi/launch_image.png new file mode 100644 index 00000000..71faafbd Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/launch_image.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f3..cce2eaf2 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -3,10 +3,10 @@ - - + - --> + android:src="@drawable/launch_image" /> + diff --git a/android/app/src/main/res/drawable-xhdpi/launch_image.png b/android/app/src/main/res/drawable-xhdpi/launch_image.png new file mode 100644 index 00000000..cdedd2f2 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/launch_image.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/launch_image.png b/android/app/src/main/res/drawable-xxhdpi/launch_image.png new file mode 100644 index 00000000..b33c9889 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/launch_image.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/launch_image.png b/android/app/src/main/res/drawable-xxxhdpi/launch_image.png new file mode 100644 index 00000000..f37b6215 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/launch_image.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f8..856b6b20 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -3,10 +3,10 @@ - - + - --> + android:src="@drawable/launch_image" /> + diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 00000000..2c7588ca --- /dev/null +++ b/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe065..d36178c8 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,6 +11,8 @@ pluginManagement { includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/public") } google() mavenCentral() gradlePluginPortal() diff --git a/assets/languages/en-US.json b/assets/languages/en-US.json index 626b3bfb..7abd423c 100644 --- a/assets/languages/en-US.json +++ b/assets/languages/en-US.json @@ -260,6 +260,130 @@ "emergency_stop": "E-Stop", "emergency_stopping": "Stopping" }, + + "pv_control": { + "manual_control": "Manual", + "pause_task": "Pause", + "screenshot": "Snapshot", + "view_map": "Map", + "emergency_stop": "E-Stop", + "emergency_stopping": "Stopping", + "sheet_mower_title": "Mower Lift", + "sheet_mower_hint": "Left: raise · Right: lower · Release: stop", + "sheet_chassis_title": "Chassis Lift", + "sheet_chassis_hint": "Left: up · Right: down · Release: stop", + "sheet_fire_title": "Ignition", + "sheet_fire_hint": "Left: ignite · Right: extinguish · Center: stop", + "seg_mower_up": "Raise", + "seg_mower_down": "Lower", + "seg_chassis_up": "Up", + "seg_chassis_down": "Down", + "seg_fire_up": "Ignite", + "seg_fire_down": "Extinguish", + "seg_stop": "Stop", + "scrub_hint": "Press & hold \"{label}\", then slide", + "toast_paused": "Task paused", + "toast_emergency_sent": "Emergency stop sent", + "toast_emergency_released": "Emergency stop released", + "toast_screenshot_failed": "Screenshot failed", + "toast_screenshot_saved": "Screenshot saved", + "toast_permission_released": "Remote control released", + "toast_permission_release_failed": "Failed to release control", + "remote_mode": "Remote Mode", + "online": "Online", + "offline": "Offline", + "unknown": "Unknown", + "nav_task": "Task", + "nav_map": "Map", + "nav_video": "Video", + "nav_settings": "Settings", + "mode_remote": "Remote", + "mode_local": "Local", + "qual_none": "No Fix", + "qual_single": "Single", + "qual_differential": "DGPS", + "qual_ins": "Dead Reckoning", + "qual_rtk_fixed": "RTK Fixed", + "qual_rtk_float": "RTK Float", + "qual_rtk_fixed_nh": "RTK Fixed (no heading)", + "qual_rtk_float_nh": "RTK Float (no heading)", + "obstacle_ahead": "Obstacle Ahead", + "view_left_pip": "Left View", + "view_right_pip": "Right View", + "rear_view": "Rear View", + "reverse_camera": "Reverse Cam", + "map_current_task": "Task: Auto Mowing - Zone 1", + "map_switch": "Switch ›", + "work_progress": "Progress", + "work_area_size": "Area", + "work_duration": "Duration", + "vehicle_status": "Vehicle", + "status_normal": "Normal", + "status_abnormal": "Abnormal", + "drive_speed": "Speed", + "gear": "Gear", + "gear_low": "Low", + "chassis_height": "Height", + "mower_status": "Mower", + "mower_running": "Running", + "battery_label": "Battery", + "current_speed": "Speed", + "patrol_mileage": "Mileage", + "network_status": "Network", + "device_temperature": "Temp", + "battery_unknown": "Battery N/A", + "battery_percent": "Battery {n}%", + "auto_driving": "Auto Driving", + "manual_controlling": "Manual Control", + "switch_mode": "Switch Mode", + "rtsp_searching": "Searching local camera…", + "rtsp_connecting": "Connecting stream…", + "rtsp_reconnecting": "Signal lost, reconnecting…", + "rtsp_not_found": "No camera found, retrying…", + "rtsp_failed": "Stream failed, retrying…", + "rtsp_no_ethernet": "No Ethernet, check the cable", + "map_waiting_push": "Waiting for location…", + "task_current": "Current Task", + "task_none_running": "No running task", + "task_field_no": "Task No.", + "task_field_device": "Device No.", + "task_field_status": "Status", + "task_field_route": "Route No.", + "task_field_site": "Plot No.", + "task_field_create_time": "Created", + "task_none_value": "None", + "task_new": "New", + "task_executing": "Running", + "task_paused": "Paused", + "set_video_source": "Source", + "set_source_rtsp": "Ethernet RTSP", + "set_source_hdmi": "HDMI", + "set_source_webrtc": "WebRTC", + "set_video_fit": "Display", + "set_fit_contain": "Fit", + "set_fit_cover": "Fill", + "set_fit_contain_hint": "No crop; letterbox if ratios differ", + "set_fit_cover_hint": "Fill with no bars; edges cropped", + "set_sim_obstacle": "Simulate Obstacle", + "set_sim_obstacle_sub": "Alert: banner + box + red flash", + "set_sim_reverse": "Reverse Camera", + "set_sim_reverse_sub": "Overlay reverse guide lines", + "perm_unknown_device": "Unknown device", + "perm_unknown_platform": "Unknown platform", + "perm_request_body": "{platform} is requesting control\n\nDevice ID: {deviceId}\n\nRelease control?", + "realtime_video": "Live Video", + "pv_robot_remote_control": "PV Robot Remote Control", + "screenshot_saved_path": "Saved: {path}", + "close": "Close", + "rail_return_home": "Return Home", + "rail_find_vehicle": "Find Vehicle", + "rail_pause_work": "Pause Work", + "rail_light": "Lights", + "rail_horn": "Horn", + "rail_record_shot": "Record/Snapshot", + "speed_high": "High", + "speed_low": "Low" + }, "route_planning": { "title": "Route Planning", diff --git a/assets/languages/zh-CN.json b/assets/languages/zh-CN.json index 7ef16bb1..e1ba3914 100644 --- a/assets/languages/zh-CN.json +++ b/assets/languages/zh-CN.json @@ -274,6 +274,130 @@ "emergency_stop": "急停", "emergency_stopping": "急停中" }, + + "pv_control": { + "manual_control": "手动控制", + "pause_task": "暂停任务", + "screenshot": "截图", + "view_map": "查看地图", + "emergency_stop": "紧急停止", + "emergency_stopping": "急停中", + "sheet_mower_title": "割刀升降", + "sheet_mower_hint": "左滑抬刀 · 右滑降刀 · 松手停", + "sheet_chassis_title": "底盘升降", + "sheet_chassis_hint": "左滑升 · 右滑降 · 松手停", + "sheet_fire_title": "点火", + "sheet_fire_hint": "左滑点火 · 右滑熄火 · 中间停", + "seg_mower_up": "抬刀", + "seg_mower_down": "降刀", + "seg_chassis_up": "升高", + "seg_chassis_down": "降低", + "seg_fire_up": "点火", + "seg_fire_down": "熄火", + "seg_stop": "停止", + "scrub_hint": "长按「{label}」后左右滑动", + "toast_paused": "已暂停任务", + "toast_emergency_sent": "已发送紧急停止", + "toast_emergency_released": "已解除紧急停止", + "toast_screenshot_failed": "截图失败", + "toast_screenshot_saved": "截图已保存", + "toast_permission_released": "已释放远程控制权限", + "toast_permission_release_failed": "控制权限释放失败", + "remote_mode": "遥控模式", + "online": "在线", + "offline": "离线", + "unknown": "未知", + "nav_task": "任务", + "nav_map": "地图", + "nav_video": "视频", + "nav_settings": "设置", + "mode_remote": "远程", + "mode_local": "本地", + "qual_none": "无定位", + "qual_single": "单点定位", + "qual_differential": "差分定位", + "qual_ins": "惯性导航", + "qual_rtk_fixed": "RTK 固定解", + "qual_rtk_float": "RTK 浮点解", + "qual_rtk_fixed_nh": "RTK 固定(无航向)", + "qual_rtk_float_nh": "RTK 浮点(无航向)", + "obstacle_ahead": "前方有障碍物", + "view_left_pip": "左视角", + "view_right_pip": "右视角", + "rear_view": "后视图", + "reverse_camera": "倒车影像", + "map_current_task": "当前任务:自主割草-1区", + "map_switch": "切换 ›", + "work_progress": "作业进度", + "work_area_size": "作业面积", + "work_duration": "作业时长", + "vehicle_status": "车辆状态", + "status_normal": "正常", + "status_abnormal": "异常", + "drive_speed": "行驶速度", + "gear": "档位", + "gear_low": "低速", + "chassis_height": "底盘高度", + "mower_status": "割刀状态", + "mower_running": "运行中", + "battery_label": "电池电量", + "current_speed": "当前速度", + "patrol_mileage": "巡检里程", + "network_status": "网络状态", + "device_temperature": "设备温度", + "battery_unknown": "电量 未知", + "battery_percent": "电量 {n}%", + "auto_driving": "自动驾驶中", + "manual_controlling": "手动控制中", + "switch_mode": "切换模式", + "rtsp_searching": "正在搜索本地摄像头…", + "rtsp_connecting": "正在连接视频流…", + "rtsp_reconnecting": "信号中断,自动重连中…", + "rtsp_not_found": "未发现摄像头,自动重试中…", + "rtsp_failed": "拉流失败,自动重试中…", + "rtsp_no_ethernet": "未检测到以太网,请检查网线", + "map_waiting_push": "等待定位推送…", + "task_current": "当前任务", + "task_none_running": "暂无执行中的任务", + "task_field_no": "任务编号", + "task_field_device": "设备编号", + "task_field_status": "任务状态", + "task_field_route": "路线编号", + "task_field_site": "地块编号", + "task_field_create_time": "创建时间", + "task_none_value": "无", + "task_new": "新建", + "task_executing": "执行中", + "task_paused": "暂停中", + "set_video_source": "视频源", + "set_source_rtsp": "以太网 RTSP 源", + "set_source_hdmi": "HDMI", + "set_source_webrtc": "WebRTC", + "set_video_fit": "视频展示", + "set_fit_contain": "完整显示", + "set_fit_cover": "铺满屏幕", + "set_fit_contain_hint": "画面不裁,窗口比例不合时上下或左右留黑边", + "set_fit_cover_hint": "等比放大到无黑边,超出窗口的边缘被裁掉", + "set_sim_obstacle": "模拟障碍物信息", + "set_sim_obstacle_sub": "收到告警推送:横幅 + 障碍框 + 屏幕泛红同时出现", + "set_sim_reverse": "倒车影像", + "set_sim_reverse_sub": "后视卡叠加倒车引导线(模拟倒车挡)", + "perm_unknown_device": "未知设备", + "perm_unknown_platform": "未知平台", + "perm_request_body": "{platform} 端正在请求控制权\n\n设备ID: {deviceId}\n\n是否同意释放控制权?", + "realtime_video": "实时视频", + "pv_robot_remote_control": "光伏电站机器人远程控制", + "screenshot_saved_path": "已保存:{path}", + "close": "关闭", + "rail_return_home": "一键返航", + "rail_find_vehicle": "一键寻车", + "rail_pause_work": "暂停作业", + "rail_light": "灯光", + "rail_horn": "鸣笛", + "rail_record_shot": "录像截图", + "speed_high": "高速", + "speed_low": "低速" + }, "route_planning": { "title": "路径规划", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367e..2a841826 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,10 +1,10 @@ import Flutter import UIKit -@main +@mainlication( + _ application @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, + override func app: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { return super.application(application, didFinishLaunchingWithOptions: launchOptions) diff --git a/lib/components/language_selection_dialog.dart b/lib/components/language_selection_dialog.dart index e38767b5..a171e439 100644 --- a/lib/components/language_selection_dialog.dart +++ b/lib/components/language_selection_dialog.dart @@ -38,8 +38,8 @@ class AppLanguages { } /// 语言选择弹窗。 -/// [mandatory] = true:登录时强制选择,不可返回/点外部关闭; -/// [mandatory] = false:设置页中可随时关闭。 +/// [mandatory] = true:登录时强制弹出,拦截系统返回键;可点某个语言即时切换,或点“继续”确认当前默认语言后关闭。 +/// [mandatory] = false:设置页中普通选择,可取消。 class LanguageSelectionDialog extends StatelessWidget { final bool mandatory; const LanguageSelectionDialog({super.key, this.mandatory = true}); @@ -49,6 +49,12 @@ class LanguageSelectionDialog extends StatelessWidget { if (context.mounted) Navigator.of(context).pop(); } + /// “继续”:不改变当前(默认)语言,仅确认已选并关闭,后续启动不再弹。 + Future _onContinue(BuildContext context, Locale current) async { + await context.read().setLocale(current); + if (context.mounted) Navigator.of(context).pop(); + } + @override Widget build(BuildContext context) { final current = context.select( @@ -79,14 +85,15 @@ class LanguageSelectionDialog extends StatelessWidget { ], ), ), - actions: mandatory - ? null - : [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('取消'), - ), - ], + actions: [ + // 强制弹窗:提供“继续”出口,保持当前默认语言并确认后关闭;非强制:取消 + TextButton( + onPressed: () => mandatory + ? _onContinue(context, current) + : Navigator.of(context).pop(), + child: Text(mandatory ? '继续 / Continue' : '取消'), + ), + ], ), ); } diff --git a/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart b/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart index ffe8fb5c..9464518c 100644 --- a/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart @@ -8,6 +8,7 @@ import '../../domain/models/mqtt_message.dart'; import '../../domain/entities/mower_location_entity.dart'; import '../../domain/entities/real_time_message_entity.dart'; import '../../domain/entities/route_status_entity.dart'; +import '../../domain/entities/host_realtime_message_entity.dart'; /// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送) /// @@ -20,6 +21,9 @@ abstract class MowerRealtimeDataSource { Stream get locationStream; Stream get routeStatusStream; + /// 🔥 主机实时状态(device/{sn}/host/realTime/message,新增话题,纯附加) + Stream get hostRealtimeStream; + /// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重 Future startListening({required String deviceSn}); @@ -32,11 +36,14 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { static const String _realtimeSuffix = 'property/realtime/post'; static const String _locationSuffix = 'property/location/post'; static const String _routeSuffix = 'route/post'; + static const String _hostRealtimeSuffix = 'host/realTime/message'; final _vehicleController = StreamController.broadcast(); final _locationController = StreamController.broadcast(); final _routeController = StreamController.broadcast(); + final _hostRealtimeController = + StreamController.broadcast(); StreamSubscription? _subscription; @@ -47,6 +54,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { int _vehicleMsgCount = 0; int _locationMsgCount = 0; int _routeMsgCount = 0; + int _hostMsgCount = 0; MowerRealtimeDataSourceImpl(this.mqttClient); @@ -59,9 +67,14 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { @override Stream get routeStatusStream => _routeController.stream; + @override + Stream get hostRealtimeStream => + _hostRealtimeController.stream; + String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix'; String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix'; String _routeTopic(String sn) => 'device/$sn/$_routeSuffix'; + String _hostRealtimeTopic(String sn) => 'device/$sn/$_hostRealtimeSuffix'; @override Future startListening({required String deviceSn}) async { @@ -81,6 +94,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!)); await mqttClient.unsubscribe(_locationTopic(_deviceSn!)); await mqttClient.unsubscribe(_routeTopic(_deviceSn!)); + await mqttClient.unsubscribe(_hostRealtimeTopic(_deviceSn!)); debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn'); } @@ -88,10 +102,12 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { _vehicleMsgCount = 0; _locationMsgCount = 0; _routeMsgCount = 0; + _hostMsgCount = 0; await mqttClient.subscribe(_realtimeTopic(deviceSn)); await mqttClient.subscribe(_locationTopic(deviceSn)); await mqttClient.subscribe(_routeTopic(deviceSn)); + await mqttClient.subscribe(_hostRealtimeTopic(deviceSn)); // 消息流监听只建立一次,按 _deviceSn 过滤 _subscription ??= mqttClient.messageStream?.listen(_handleMessage); @@ -100,6 +116,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}'); debugPrint(' 定位: ${_locationTopic(deviceSn)}'); debugPrint(' 路径规划: ${_routeTopic(deviceSn)}'); + debugPrint(' 主机实时: ${_hostRealtimeTopic(deviceSn)}'); } @override @@ -109,6 +126,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!)); await mqttClient.unsubscribe(_locationTopic(_deviceSn!)); await mqttClient.unsubscribe(_routeTopic(_deviceSn!)); + await mqttClient.unsubscribe(_hostRealtimeTopic(_deviceSn!)); debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn'); _deviceSn = null; } @@ -153,6 +171,16 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { final location = MowerLocationEntity.fromJson(dataObj); debugPrint('${LogTime.now()} 📍 [MQTT-机器状态] 定位第$_locationMsgCount帧 解析后: lat=${location.latitude}, lng=${location.longitude}, alt=${location.altitude}, heading=${location.heading}, speed=${location.speed}'); _locationController.add(location); + } else if (message.topic.endsWith(_hostRealtimeSuffix)) { + // 🔥 主机实时(新增话题,纯附加):外层套信封,数据在 json.data,提取方式与定位一致 + _hostMsgCount++; + if (_hostMsgCount == 1) { + debugPrint('${LogTime.now()} 🎉 [MQTT-主机实时] 首帧到达: $sn'); + } + final dataObj = jsonData['data'] as Map? ?? jsonData; + final host = HostRealtimeMessageEntity.fromJson(dataObj); + debugPrint('${LogTime.now()} 🛰️ [MQTT-主机实时] 第$_hostMsgCount帧 电量=${host.batteryPercentage}% 电压=${host.batteryVoltage}V 控制模式=${host.controlMode} 急停=${host.emergencyStop} 运动=${host.motion}'); + _hostRealtimeController.add(host); } else if (message.topic.endsWith(_routeSuffix)) { _routeMsgCount++; if (_routeMsgCount == 1) { @@ -174,5 +202,6 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { _vehicleController.close(); _locationController.close(); _routeController.close(); + _hostRealtimeController.close(); } } diff --git a/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart b/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart index 992237ac..5e659712 100644 --- a/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart +++ b/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart @@ -7,6 +7,7 @@ import '../../domain/repositories/mower_realtime_repository.dart'; import '../../domain/entities/mower_location_entity.dart'; import '../../domain/entities/real_time_message_entity.dart'; import '../../domain/entities/route_status_entity.dart'; +import '../../domain/entities/host_realtime_message_entity.dart'; class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository { final MowerRealtimeDataSource dataSource; @@ -23,6 +24,10 @@ class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository { Stream get routeStatusStream => dataSource.routeStatusStream; + @override + Stream get hostRealtimeStream => + dataSource.hostRealtimeStream; + @override Future> startListening({ required String deviceSn, diff --git a/lib/core/network/mqtt/domain/entities/host_realtime_message_entity.dart b/lib/core/network/mqtt/domain/entities/host_realtime_message_entity.dart new file mode 100644 index 00000000..5e46f4c0 --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/host_realtime_message_entity.dart @@ -0,0 +1,132 @@ +import 'package:equatable/equatable.dart'; + +/// 主机实时状态消息(MQTT 主题 device/{sn}/host/realTime/message) +/// +/// 报文外层套一层信封,真正数据在 `data` 子对象(由 DataSource 提取后传入)。 +/// 字段与后端 Java 实体一一对应,采用防御式解析,缺字段用默认值兜底。 +class HostRealtimeMessageEntity extends Equatable { + final double batteryPercentage; // 实时电量百分比 + final double batteryVoltage; // 实时电压 + final double leftMotorCurrent; // 实时左电机电流 + final double rightMotorCurrent; // 实时右电机电流 + final double leftMotorTemperature; // 实时左电机温度 + final double rightMotorTemperature; // 实时右电机温度 + final double mainTemperature; // 实时主温度 + final double yaw; // 实时航向角度 + final double pitch; // 实时俯仰角度 + final String headingStatus; // 实时航向状态 + final double totalWorkArea; // 累计作业面积 + final double totalWorkDuration; // 累计作业时长 + final double totalWorkDistance; // 累计作业里程 + final String motion; // moving|stopped|blocked 运动状态 + final bool emergencyStop; // 急停状态 + final double leftTargetRpm; // 实时左电机目标转速 + final double rightTargetRpm; // 实时右电机目标转速 + final double leftMeasuredRpm; // 实时左电机测量转速 + final double rightMeasuredRpm; // 实时右电机测量转速 + final double leftMotorRpm; // 实时左电机转速 + final double rightMotorRpm; // 实时右电机转速 + final double roll; // 实时翻滚角度 + final String controlMode; // 当前控制模式(遥控器拨杆对应) + + /// 原始 data 报文,便于后端补字段时兜底 + final Map rawData; + + const HostRealtimeMessageEntity({ + this.batteryPercentage = 0, + this.batteryVoltage = 0, + this.leftMotorCurrent = 0, + this.rightMotorCurrent = 0, + this.leftMotorTemperature = 0, + this.rightMotorTemperature = 0, + this.mainTemperature = 0, + this.yaw = 0, + this.pitch = 0, + this.headingStatus = '', + this.totalWorkArea = 0, + this.totalWorkDuration = 0, + this.totalWorkDistance = 0, + this.motion = '', + this.emergencyStop = false, + this.leftTargetRpm = 0, + this.rightTargetRpm = 0, + this.leftMeasuredRpm = 0, + this.rightMeasuredRpm = 0, + this.leftMotorRpm = 0, + this.rightMotorRpm = 0, + this.roll = 0, + this.controlMode = '', + this.rawData = const {}, + }); + + /// [data] 为报文 `data` 子对象(由 DataSource 提取,与 MowerLocationEntity 一致) + factory HostRealtimeMessageEntity.fromJson(Map data) { + return HostRealtimeMessageEntity( + batteryPercentage: _toDouble(data['battery_percentage']), + batteryVoltage: _toDouble(data['battery_voltage']), + leftMotorCurrent: _toDouble(data['left_motor_current']), + rightMotorCurrent: _toDouble(data['right_motor_current']), + leftMotorTemperature: _toDouble(data['left_motor_temperature']), + rightMotorTemperature: _toDouble(data['right_motor_temperature']), + mainTemperature: _toDouble(data['main_temperature']), + yaw: _toDouble(data['yaw']), + pitch: _toDouble(data['pitch']), + headingStatus: data['heading_status']?.toString() ?? '', + totalWorkArea: _toDouble(data['total_work_area']), + totalWorkDuration: _toDouble(data['total_work_duration']), + totalWorkDistance: _toDouble(data['total_work_distance']), + motion: data['motion']?.toString() ?? '', + emergencyStop: _toBool(data['emergency_stop']), + leftTargetRpm: _toDouble(data['left_target_rpm']), + rightTargetRpm: _toDouble(data['right_target_rpm']), + leftMeasuredRpm: _toDouble(data['left_measured_rpm']), + rightMeasuredRpm: _toDouble(data['right_measured_rpm']), + leftMotorRpm: _toDouble(data['left_motor_rpm']), + rightMotorRpm: _toDouble(data['right_motor_rpm']), + roll: _toDouble(data['roll']), + controlMode: data['control_mode']?.toString() ?? '', + rawData: data, + ); + } + + static double _toDouble(dynamic value) { + if (value == null) return 0; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()) ?? 0; + } + + static bool _toBool(dynamic value) { + if (value == null) return false; + if (value is bool) return value; + if (value is num) return value != 0; + final s = value.toString().toLowerCase(); + return s == 'true' || s == '1'; + } + + @override + List get props => [ + batteryPercentage, + batteryVoltage, + leftMotorCurrent, + rightMotorCurrent, + leftMotorTemperature, + rightMotorTemperature, + mainTemperature, + yaw, + pitch, + headingStatus, + totalWorkArea, + totalWorkDuration, + totalWorkDistance, + motion, + emergencyStop, + leftTargetRpm, + rightTargetRpm, + leftMeasuredRpm, + rightMeasuredRpm, + leftMotorRpm, + rightMotorRpm, + roll, + controlMode, + ]; +} diff --git a/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart b/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart index b194432c..64252a36 100644 --- a/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart +++ b/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart @@ -5,11 +5,13 @@ import '../../../../../core/error/failure.dart'; import '../entities/mower_location_entity.dart'; import '../entities/real_time_message_entity.dart'; import '../entities/route_status_entity.dart'; +import '../entities/host_realtime_message_entity.dart'; abstract class MowerRealtimeRepository { Stream get vehicleStream; Stream get locationStream; Stream get routeStatusStream; + Stream get hostRealtimeStream; Future> startListening({ required String deviceSn, diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index 4ad66793..5bf5d3e4 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -41,7 +41,10 @@ class TcpClient { Timer? _heartbeatSendTimer; // 🔥 主动发送心跳定时器:每5秒给服务端发一次,防止服务端判定客户端失效 Timer? _reconnectTimer; // 重连定时器 int _reconnectAttempt = 0; // 🔥 重连尝试次数,用于退避计算 + static const int _maxReconnectAttempts = 5; // 🔥 最大自动重连次数,防止网络不可达时无限重连 int _reconnectFailCount = 0; // 🔥 连续重连失败次数(含心跳超时),达到阈值后停止重连 + int _reconnectFlapCount = 0; // 🔥 连续“连上又断”次数,识别成功-断开循环 + static const int _maxReconnectFlaps = 5; // 🔥 最大允许连续成功-断开次数 /// 🔥 重连耗尽回调:连续4次重连失败(含心跳超时)后触发,通知上层弹窗提示用户 VoidCallback? onReconnectExhausted; /// 🔥 重连开始回调:显示"TCP重连中"提示 @@ -156,6 +159,7 @@ class TcpClient { _lastAuthPacketSentAt = null; _reconnectAttempt = 0; // 🔥 重置重连退避 _reconnectFailCount = 0; // 🔥 重置重连失败计数 + _reconnectFlapCount = 0; // 🔥 重置连上又断计数 // 5. 销毁 Socket if (_socket != null) { @@ -385,6 +389,15 @@ class TcpClient { stopHeartbeat(); _socket = null; + // 🔥 每次断开都累计一次 flap(连上又断),识别成功-立即断开循环 + _reconnectFlapCount++; + debugPrint('📊 [TCP] 连接断开次数: $_reconnectFlapCount/$_maxReconnectFlaps'); + if (_reconnectFlapCount >= _maxReconnectFlaps) { + debugPrint('🛑 [TCP] 连续 $_maxReconnectFlaps 次连上后立刻断开,停止重连,通知用户退出登录'); + _logger.logWithLevel('🛑 [TCP] 连续 $_maxReconnectFlaps 次连上后立刻断开,停止重连', shouldLog: true); + onReconnectExhausted?.call(); + return; + } // 🔥 连续失败达到4次,停止重连,通知上层弹窗 if (_reconnectFailCount >= 4) { debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录'); @@ -400,6 +413,14 @@ class TcpClient { if (isUserSwitch) return; if (_reconnectTimer != null) return; + // 🔥 限制总重连次数,防止网络不可达时无限重连 + if (_reconnectAttempt >= _maxReconnectAttempts) { + debugPrint('🛑 [TCP] 自动重连已达最大次数 $_maxReconnectAttempts 次,停止重连,通知用户退出登录'); + _logger.logWithLevel('🛑 [TCP] 自动重连已达最大次数 $_maxReconnectAttempts 次,停止重连', shouldLog: true); + onReconnectExhausted?.call(); + return; + } + // 🔥 重连退避:5s → 10s → 20s → 30s(max) final delay = [5, 10, 20, 30][_reconnectAttempt.clamp(0, 3)]; _reconnectAttempt++; @@ -540,11 +561,31 @@ class TcpClient { ..addByte(0xAA) ..addByte(0xAB); - _socket!.add(builder.takeBytes()); - // 🔥 关键修复:强制flush,确保数据立即发送 - _socket!.flush(); + final packet = builder.takeBytes(); + try { + _socket!.add(packet); + // 🔥 关键修复:强制flush,确保数据立即发送 + _socket!.flush(); + // 联调日志:移到 add+flush 之后打印,证明这帧已真正交给系统 socket 发送缓冲 + // ("发出去之后"才有参考价值;仍非后端 ACK,仅代表 App 侧写入成功)(联调结束可移除) + _logger.logWithLevel( + '🔗 [联调-TCP] sendRaw ✅已写入socket cmd=0x${cmd.toRadixString(16).padLeft(2, '0')} ' + 'len=${packet.length} frameHex=${_hex(packet)}', + shouldLog: true, + ); + } catch (e) { + _logger.logWithLevel( + '🔗 [联调-TCP] sendRaw ❌写入socket失败 cmd=0x${cmd.toRadixString(16).padLeft(2, '0')} err=$e', + level: 'ERROR', + shouldLog: true, + ); + } } + /// 字节转十六进制字符串,供联调对包使用 + String _hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + void sendHeartbeat() { if (_socket == null) return; final builder = BytesBuilder() @@ -580,6 +621,7 @@ class TcpClient { /// 重置心跳超时定时器 /// 每次收到服务端心跳(0xFF)时调用,重新开始计时 void _resetHeartbeatTimeout() { + final wasRunning = _heartbeatTimer != null; _heartbeatTimer?.cancel(); _heartbeatTimer = Timer(_heartbeatTimeout, () { debugPrint('⚠️ [TCP] 心跳超时 ${_heartbeatTimeout.inSeconds}s 未收到服务端心跳,判定连接断开'); @@ -595,6 +637,12 @@ class TcpClient { // 🔥 统一走普通断开重连路径(_scheduleReconnect 已包含 HTTP switchDevice) _handleDisconnect(); }); + + // 🔥 收到心跳说明连接真正稳定,重置“连上又断”计数 + if (wasRunning && _reconnectFlapCount > 0) { + _reconnectFlapCount = 0; + debugPrint('📊 [TCP] 收到心跳,连接稳定,重置连接断开计数'); + } } /// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳 @@ -784,6 +832,7 @@ class TcpClient { _connectionCompleter = null; _reconnectAttempt = 0; // 🔥 重置重连退避 _reconnectFailCount = 0; // 🔥 重置重连失败计数 + _reconnectFlapCount = 0; // 🔥 重置连上又断计数 if (_socket != null) { _socket!.destroy(); _socket = null; diff --git a/lib/core/protocol/machine_protocol_codec.dart b/lib/core/protocol/machine_protocol_codec.dart index c2fea862..e6359144 100644 --- a/lib/core/protocol/machine_protocol_codec.dart +++ b/lib/core/protocol/machine_protocol_codec.dart @@ -2,22 +2,27 @@ import 'dart:typed_data'; /// 极简化的 Codec:只负责中间那段变化的“负载数据” class MachineProtocolCodec { - /// 仅封装 0x00 指令的数据部分 (Byte 3 到 Byte 10,共 8 字节) + /// 仅封装 0x00 指令的数据部分(共 20 字节) + /// + /// 协议变更(2026-10):左右轮速 short(int16) 改为线速度/角速度 double: + /// - [0..7] linear float64 小端(线速度 m/s) + /// - [8..15] angular float64 小端(角速度 rad/s) + /// - [16] lift / [17] mower / [18] ignition / [19] emergency(不变) static Uint8List encodeRemoteControlPayload({ - required int left, - required int right, + required double linear, + required double angular, int lift = 0, int mower = 0, int ignition = 0, int emergency = 0, }) { - final bd = ByteData(8); // 只分配 8 字节 + final bd = ByteData(20); // 8 + 8 + 4 字节 int pos = 0; - bd.setInt16(pos, left, Endian.little); - pos += 2; - bd.setInt16(pos, right, Endian.little); - pos += 2; + bd.setFloat64(pos, linear, Endian.little); + pos += 8; + bd.setFloat64(pos, angular, Endian.little); + pos += 8; bd.setUint8(pos++, lift); bd.setUint8(pos++, mower); bd.setUint8(pos++, ignition); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index b6192254..96d38d95 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/worko import '../../features/auth/presentation/bloc/auth_cubit.dart'; import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/machine_details/presentation/pages/machine_details_page.dart'; +import '../../features/pv_control/presentation/pages/pv_control_page.dart'; import '../../features/main_container/presentation/main_wrapper.dart'; import '../../main.dart'; // 🔥 导入 navigatorKey import 'go_router_refresh_stream.dart'; @@ -67,6 +68,19 @@ GoRouter createRouter(AuthCubit authCubit) { }, ), + GoRoute( + path: RoutePaths.pvControl, + name: 'pvControl', + builder: (context, state) { + // extra: {'deviceId': String, 'deviceName': String?} + final extra = state.extra as Map? ?? {}; + return PvControlPage( + deviceId: (extra['deviceId'] as String?) ?? '', + deviceName: extra['deviceName'] as String?, + ); + }, + ), + GoRoute( path: RoutePaths.home, builder: (context, state) => const MainWrapper(), diff --git a/lib/core/router/route_paths.dart b/lib/core/router/route_paths.dart index bff7545c..90952a01 100644 --- a/lib/core/router/route_paths.dart +++ b/lib/core/router/route_paths.dart @@ -24,6 +24,9 @@ class RoutePaths { static const machineDetails = '/machine_details/machine_details'; + /// 光伏电站机器人远程控制(横屏大屏)页面 + static const pvControl = '/pv_control'; + /// 工单相关页面 static const workOrder = '/workorder'; static const workOrderDetail = '/workorder/detail/:orderId'; diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 6901f5bb..924a854d 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; -import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart'; import 'package:maibu_satabot_v2/components/language_selection_dialog.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; @@ -47,11 +46,10 @@ class _LoginPageState extends State with SingleTickerProviderStateMix }); } - /// 进入登录页:若用户从未选择过语言,弹出强制语言选择框。 - /// 选择后 LocaleCubit.setLocale 触发 MaterialApp 重建,登录页立即显示对应语言。 + /// 进入登录页:每次都弹出语言选择框,由用户当场决定。 + /// 点某个语言 → 立即切换;点“继续” → 保持当前(默认)语言关闭。 + /// 无论哪种,LocaleCubit.setLocale 触发 MaterialApp 重建,登录页立即显示对应语言。 void _maybeShowLanguageDialog() { - final localeCubit = context.read(); - if (localeCubit.hasChosenLanguage) return; showDialog( context: context, barrierDismissible: false, diff --git a/lib/features/devices/domain/entities/running_status_entity.dart b/lib/features/devices/domain/entities/running_status_entity.dart index e2693385..69cc1c5a 100644 --- a/lib/features/devices/domain/entities/running_status_entity.dart +++ b/lib/features/devices/domain/entities/running_status_entity.dart @@ -42,6 +42,10 @@ class RunningStatusEntity extends Equatable { final String workingArea; // [22] 工作面积 final String obstacleFlag; // [23] 障碍物标志 + // === 主机实时话题(device/{sn}/host/realTime/message)扩展字段(新增,纯附加) === + final double totalWorkDistance; // host 累计作业里程(供"巡检里程"展示) + final bool hostDataReceived; // 是否已收到 host 实时数据(未收到时 UI 显示"未知") + const RunningStatusEntity({ this.voltage = 0.0, this.leftTargetSpeed = 0.0, @@ -67,6 +71,8 @@ class RunningStatusEntity extends Equatable { this.battery = '0', this.workingArea = '0', this.obstacleFlag = '0', + this.totalWorkDistance = 0.0, + this.hostDataReceived = false, }); /// 从字段数组创建实体 (核心解析方法) @@ -166,6 +172,8 @@ class RunningStatusEntity extends Equatable { String? battery, String? workingArea, String? obstacleFlag, + double? totalWorkDistance, + bool? hostDataReceived, }) { return RunningStatusEntity( voltage: voltage ?? this.voltage, @@ -192,6 +200,8 @@ class RunningStatusEntity extends Equatable { battery: battery ?? this.battery, workingArea: workingArea ?? this.workingArea, obstacleFlag: obstacleFlag ?? this.obstacleFlag, + totalWorkDistance: totalWorkDistance ?? this.totalWorkDistance, + hostDataReceived: hostDataReceived ?? this.hostDataReceived, ); } @@ -221,6 +231,8 @@ class RunningStatusEntity extends Equatable { battery, workingArea, obstacleFlag, + totalWorkDistance, + hostDataReceived, ]; @override diff --git a/lib/features/pv_control/presentation/channel/pv_robot_command_channel.dart b/lib/features/pv_control/presentation/channel/pv_robot_command_channel.dart new file mode 100644 index 00000000..efc59c94 --- /dev/null +++ b/lib/features/pv_control/presentation/channel/pv_robot_command_channel.dart @@ -0,0 +1,109 @@ +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; + +/// 遥控指令的**传输抽象**(预留扩展)。 +/// +/// HUD / 页面只调用这里的「语义方法」,不关心底层是 TCP、蓝牙还是下位机连接: +/// 将来新增通信方式时,只需再写一个实现类(如 `BleRobotCommandChannel`), +/// 由调用方决定注入哪一个实现,UI 与控制语义代码零改动。 +/// +/// 段位值统一沿用远程遥控编码:上=1、中=0、下=2 +/// (割刀 1 抬 / 2 降、底盘 1 升 / 2 降、点火 1 点火 / 2 熄火,0 都是停止)。 +abstract interface class PvRobotCommandChannel { + /// 前进 / 后退(左摇杆,-100~100,上推为正=前进)。 + void sendDriveY(int y); + + /// 左转 / 右转(右摇杆,-100~100)。 + void sendDriveX(int x); + + /// 底盘升降。 + void sendChassis(int cmd); + + /// 割刀升降。 + void sendMower(int cmd); + + /// 点火 / 熄火。 + void sendIgnition(int cmd); + + /// 紧急停止。 + void sendEmergencyStop(bool on); + + /// 双轴归零(松手即停,安全底线)。 + Future stopAllMovement(); + + /// 进入控制态:查询一次控制权限,并开始持续下发控制帧(默认 100ms 控制循环)。 + void activate(); + + /// 退出控制态:归零、停止控制循环、释放控制权、复位权限。 + /// + /// 返回释放接口结果(`true` 成功 / `false` 失败);无目标设备、未发起释放时 + /// 返回 `null`,供页面决定是否弹出与远程遥控页一致的释放提示。 + Future deactivate(); +} + +/// 基于 TCP 的实现:委托现有 [RemoteControlCubit] 单例,**不改动远程遥控逻辑**, +/// 因此与极简模式的远程遥控页共用同一套协议、权限与控制循环。 +class RemoteControlRobotChannel implements PvRobotCommandChannel { + const RemoteControlRobotChannel(); + + RemoteControlCubit get _cubit => sl(); + + @override + void sendDriveY(int y) => _cubit.updateOriginY(y); + + @override + void sendDriveX(int x) => _cubit.updateOriginX(x); + + @override + void sendChassis(int cmd) => _cubit.sendChassisCommand(cmd); + + @override + void sendMower(int cmd) => _cubit.sendMowerCommand(cmd); + + @override + void sendIgnition(int cmd) => _cubit.sendFireCommand(cmd); + + @override + void sendEmergencyStop(bool on) => _cubit.updateEmergency(on); + + @override + Future stopAllMovement() => _cubit.stopAllMovement(); + + @override + void activate() { + final cubit = _cubit; + // 与远程遥控页一致:进控制态先「仅查询」一次控制权限(grabControl=false, + // 只把接口结果如实同步到 hasPermission,不发 TCP 抢占),再启动 100ms 控制循环。 + // 无权限时控制循环会自动跳过发送,行为与极简模式完全相同。 + final deviceName = cubit.state.targetDevice?.deviceName; + if (deviceName != null && deviceName.isNotEmpty) { + // 与远程遥控页一样不等待回包(fire-and-forget)。 + cubit.requestControlPermissionS( + deviceName, + 'app', + source: '全功能HUD进入', + grabControl: false, + ); + } + cubit.startControlLoop(); + } + + @override + Future deactivate() async { + // 先解除急停,避免 HUD 退出时残留 isEmergency=true, + // 让下一次进入(含极简远程遥控页)控制循环被锁住无法运动。 + _cubit.updateEmergency(false); + // 停止指令无视权限强制发送,先归零再停循环,避免残留运动。 + await _cubit.stopAllMovement(); + _cubit.stopControlLoop(); + // 与远程遥控页一致:退出释放远程控制权限(type=2),设备名取当前 targetDevice。 + final deviceId = _cubit.state.targetDevice?.deviceName; + bool? released; + if (deviceId != null && deviceId.isNotEmpty) { + released = await _cubit.releasePermission(deviceId: deviceId, type: 2); + } + _cubit.resetPermissionState(); + // 把释放结果回传给页面,用于弹与远程遥控页一致的成功/失败提示。 + return released; + } +} diff --git a/lib/features/pv_control/presentation/models/pv_telemetry.dart b/lib/features/pv_control/presentation/models/pv_telemetry.dart new file mode 100644 index 00000000..08f59781 --- /dev/null +++ b/lib/features/pv_control/presentation/models/pv_telemetry.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; + +/// 光伏巡检机器人实时遥测数据(本批次为 Mock 假数据,后续可替换为 MQTT/TCP 真实来源) +/// +/// 采用不可变对象 + copyWith,便于后续接入真实数据流时局部更新字段。 +@immutable +class PvTelemetry { + const PvTelemetry({ + this.deviceName = '光伏巡检机器人', + this.serialNumber = 'PV-RB-001', + this.isOnline = true, + this.battery, + this.isAutoDriving = true, + this.taskStatus = '巡检任务进行中', + this.speed = 0.3, + this.mileage, + this.networkNormal = true, + this.temperature, + this.fixStatus, + this.satelliteCount, + this.controlMode, + this.resolution = '1920 × 1080', + this.fps = 30, + this.obstacleAlert = false, + this.obstacleDistance = 1.2, + }); + + final String deviceName; + final String serialNumber; + final bool isOnline; + + /// 电量百分比 0~100;null=未收到实时数据(显示"未知") + final double? battery; + + /// true=自动驾驶中,false=手动控制 + final bool isAutoDriving; + final String taskStatus; + + /// 当前速度 m/s + final double speed; + + /// 巡检里程(累计作业里程);null=未收到实时数据(显示"未知") + final double? mileage; + + /// 网络是否正常 + final bool networkNormal; + + /// 设备温度(实时主温度)℃;null=未收到实时数据(显示"未知") + final double? temperature; + + /// 定位质量枚举值(来自 location/post 的 fix_status:4=RTK固定解);null=未知 + final int? fixStatus; + + /// 卫星数(realtime/post 尚未推送,目前无数据源);null=未知 + final int? satelliteCount; + + /// 当前控制模式原始值(host control_mode,拨杆:0无控制/1本地/2蓝牙/3TCP/4其他);null=未知 + final String? controlMode; + + final String resolution; + final int fps; + + /// 是否障碍物预警。 + /// 由机器推送驱动(真实接入前可用设置面板「模拟障碍物信息」手动触发); + /// 为 true 时预警横幅 + AR 障碍框 + 屏幕泛红呼吸同时展示,默认不亮。 + final bool obstacleAlert; + + /// 前方障碍距离 m + final double obstacleDistance; + + PvTelemetry copyWith({ + String? deviceName, + String? serialNumber, + bool? isOnline, + double? battery, + bool? isAutoDriving, + String? taskStatus, + double? speed, + double? mileage, + bool? networkNormal, + double? temperature, + int? fixStatus, + int? satelliteCount, + String? controlMode, + String? resolution, + int? fps, + bool? obstacleAlert, + double? obstacleDistance, + }) { + return PvTelemetry( + deviceName: deviceName ?? this.deviceName, + serialNumber: serialNumber ?? this.serialNumber, + isOnline: isOnline ?? this.isOnline, + battery: battery ?? this.battery, + isAutoDriving: isAutoDriving ?? this.isAutoDriving, + taskStatus: taskStatus ?? this.taskStatus, + speed: speed ?? this.speed, + mileage: mileage ?? this.mileage, + networkNormal: networkNormal ?? this.networkNormal, + temperature: temperature ?? this.temperature, + fixStatus: fixStatus ?? this.fixStatus, + satelliteCount: satelliteCount ?? this.satelliteCount, + controlMode: controlMode ?? this.controlMode, + resolution: resolution ?? this.resolution, + fps: fps ?? this.fps, + obstacleAlert: obstacleAlert ?? this.obstacleAlert, + obstacleDistance: obstacleDistance ?? this.obstacleDistance, + ); + } +} diff --git a/lib/features/pv_control/presentation/models/pv_video_fit.dart b/lib/features/pv_control/presentation/models/pv_video_fit.dart new file mode 100644 index 00000000..4daf6bbe --- /dev/null +++ b/lib/features/pv_control/presentation/models/pv_video_fit.dart @@ -0,0 +1,12 @@ +/// 视频画面在展示区域内的适配方式(设置面板「视频展示」可切换)。 +/// +/// 两种方式是客观取舍,不可能同时满足: +/// - [contain] 完整显示:画面一格不裁,比例与窗口不一致时上下 / 左右留黑边; +/// - [cover] 铺满屏幕:等比放大到铺满、无黑边,超出窗口的边缘部分被裁掉。 +enum PvVideoFit { + /// 完整显示(默认):不裁任何内容,可能留黑边。 + contain, + + /// 铺满屏幕:无黑边,等比放大后裁掉超出窗口的边缘。 + cover, +} diff --git a/lib/features/pv_control/presentation/models/pv_video_source.dart b/lib/features/pv_control/presentation/models/pv_video_source.dart new file mode 100644 index 00000000..b840b83e --- /dev/null +++ b/lib/features/pv_control/presentation/models/pv_video_source.dart @@ -0,0 +1,18 @@ +/// pv_control 页面支持的视频链路枚举(多源可扩展)。 +/// +/// 新增一种链路只需三步: +/// ① 在这里加一个枚举值; +/// ② 在 pv_control_page 的 `_wrapVideoProvider` 注册对应 cubit(若需要); +/// ③ 在 PvVideoArea / PvManualControlView 的视频层加一个渲染分支。 +/// +/// 切换 UI 暂不做,由页面字段 `_videoSource` 指定当前链路。 +enum PvVideoSource { + /// 云端 WebRTC 流(原始方案,走 webrtc:// 地址)。 + webrtc, + + /// 本地以太网 RTSP:ONVIF 自动发现 + ExoPlayer 拉流,卡死自动重拉。 + localRtsp, + + /// USB 摄像头(UVC 采集卡,PlatformView 低延迟直显)。 + usbCamera, +} diff --git a/lib/features/pv_control/presentation/pages/pv_control_page.dart b/lib/features/pv_control/presentation/pages/pv_control_page.dart new file mode 100644 index 00000000..0d888296 --- /dev/null +++ b/lib/features/pv_control/presentation/pages/pv_control_page.dart @@ -0,0 +1,801 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart'; +import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/data/uvc_camera_service.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart'; + +import '../models/pv_telemetry.dart'; +import '../channel/pv_robot_command_channel.dart'; +import '../models/pv_video_fit.dart'; +import '../models/pv_video_source.dart'; +import '../theme/pv_colors.dart'; +import '../widgets/pv_control_bar.dart'; +import '../widgets/pv_info_column.dart'; +import '../widgets/pv_manual_control_view.dart'; +import '../widgets/pv_settings_sheet.dart'; +import '../widgets/pv_video_area.dart'; +import '../widgets/pv_work_map.dart'; + +/// 页面视图模式:普通态 / 手动控制全屏态 +enum PvViewMode { normal, manualControl } + +/// 光伏电站机器人远程控制(横屏大屏)页面。 +/// +/// 布局弹性可变:顶部导航固定,下方第一行(视频 + 右侧列)随空间伸缩, +/// 第二行控制栏固定高度。右侧列可在「信息」与「地图」间切换; +/// 「手动控制」进入视频铺满全屏 + 摇杆的形态,底部折叠图标返回。 +class PvControlPage extends StatefulWidget { + const PvControlPage({super.key, required this.deviceId, this.deviceName}); + + final String deviceId; + final String? deviceName; + + @override + State createState() => _PvControlPageState(); +} + +class _PvControlPageState extends State { + /// 截图用的视频区 RepaintBoundary key + final GlobalKey _videoKey = GlobalKey(); + + /// 本批次使用 Mock 遥测数据 + final PvTelemetry _telemetry = const PvTelemetry(); + + /// 遥控指令传输通道:当前为 TCP 实现(委托 `RemoteControlCubit`), + /// 以后接蓝牙 / 下位机只换一个实现类,HUD 与本页面代码零改动。 + final PvRobotCommandChannel _channel = const RemoteControlRobotChannel(); + + /// 控制态是否已激活:进入「手动控制」才 activate,退出 / 页面销毁 deactivate。 + bool _controlActive = false; + + /// 急停开关态:HUD 急停钮每点一次翻转,经通道下发 updateEmergency。 + bool _emergencyOn = false; + + /// 远程控制单例(HUD 与原远程遥控页共用它,指令通道内部也委托它)。 + RemoteControlCubit get _cubit => sl(); + + /// 「权限交接」监听订阅:监听其它端请求控制权的推送并弹确认框。 + StreamSubscription? _permissionSubscription; + + /// 防止权限弹窗重复弹出(与远程遥控页一致)。 + bool _isShowingPermissionDialog = false; + + /// 记录上次弹窗显示态,用于检测 false→true 的上升沿。 + bool? _lastPermissionDialogState; + + PvViewMode _mode = PvViewMode.normal; + + /// 右侧列是否显示地图(点击「查看地图」切换) + bool _showMap = false; + + String _streamUrl = ''; + + /// 当前视频链路(多源可扩展):在设置面板「视频源」行切换, + /// 切换即重建对应链路 cubit(_wrapVideoProvider 挂了 ValueKey)。 + PvVideoSource _videoSource = PvVideoSource.usbCamera; + + /// 视频展示方式(设置面板「视频展示」行切换): + /// contain 完整显示(可能留黑边)/ cover 等比铺满(裁掉边缘)。 + PvVideoFit _videoFit = PvVideoFit.contain; + + /// 模拟开关(真实推送未接入前在设置面板手动触发)。 + bool _simObstacle = false; + bool _simReverse = false; + + /// 实际下发的遥测:真实字段来自全局 [DeviceStatusBloc] 的 host 实时数据 + /// (电量/巡检里程/设备温度),未收到时置 null 由 UI 显示"未知"; + /// 开启「模拟障碍物信息」相当于收到机器告警推送,叠加 obstacleAlert。 + PvTelemetry _resolveTelemetry(DeviceStatusState dsState) { + double? battery; + double? mileage; + double? temp; + int? fixStatus; + int? satelliteCount; + String? controlMode; + if (dsState is DeviceStatusUpdated) { + final s = dsState.status; + // 定位质量:来自 location/post 的 fix_status(qual) + fixStatus = s.qual; + // 控制模式:与“设备数据监控”同一口径,只要收到状态就跟随,不加 host 门控 + controlMode = s.controlMode; + // 卫星数:realtime/post 尚未推送,无数据源时保持 null 显示"未知" + if (s.satelliteCnt > 0) satelliteCount = s.satelliteCnt; + // 以下三项来自 host 实时话题(device/{sn}/host/realTime/message) + if (s.hostDataReceived) { + battery = double.tryParse(s.battery); + mileage = s.totalWorkDistance; + temp = s.chipTemp; + } + } + return _telemetry.copyWith( + obstacleAlert: _simObstacle, + battery: battery, + mileage: mileage, + temperature: temp, + fixStatus: fixStatus, + satelliteCount: satelliteCount, + controlMode: controlMode, + ); + } + + @override + void initState() { + super.initState(); + _enterLandscape(); + _initVideoUrl(); + _ensureRealtimeListening(); + // 进入安全模式:防止异地登录推送把控制踢下线(与远程遥控页一致)。 + sl().enterSafeMode(); + // 监听「权限交接」:其它端请求控制权时弹确认框,整页生命周期常驻。 + _initPermissionHandoverListener(); + // 进页面即建立控制会话:查询权限 + 启动控制循环(等价远程遥控页进页行为)。 + WidgetsBinding.instance.addPostFrameCallback((_) => _startControl()); + } + + /// 本页的视频、地图、遥测都是围绕 `deviceId` 这一台设备,所以进页时确保 + /// MQTT 实时定位/路线订阅就指向它(全局单例 bloc,数据源内部同 SN 去重、 + /// 自动退旧订新,重复调用无副作用)。退出页面不切回去:语义与设备列表 + /// 正常切设备一致,切回去反而会让别处(如路径规划)突然断流。 + void _ensureRealtimeListening() { + if (widget.deviceId.isEmpty) return; + context.read().startMqttRealtimeListening( + widget.deviceId, + ); + } + + /// 进入页面强制横屏 + void _enterLandscape() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + /// 离开页面恢复竖屏 + 系统栏 + void _restoreOrientation() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + + void _initVideoUrl() { + final userState = context.read().state; + final token = userState.user?.token; + if (widget.deviceId.isNotEmpty && token != null) { + setState(() { + _streamUrl = + 'webrtc://${TCPConsts.TCP_IP}/live/livestream/${widget.deviceId}?token=$token'; + }); + } else { + debugPrint('❌ [PvControl] 视频URL初始化失败:deviceId 或 token 为空'); + } + } + + @override + void dispose() { + // 离开安全模式:启动 90 秒倒计时(与远程遥控页一致)。 + sl().exitSafeMode(); + // 取消「权限交接」弹窗监听,避免回调打到已销毁的 State。 + _permissionSubscription?.cancel(); + _permissionSubscription = null; + // 退出控制态:归零、停循环、释放权限(type=2)、解除急停、复位权限态。 + _stopControl(); + _restoreOrientation(); + super.dispose(); + } + + // ===== 交互回调 ===== + + void _onViewMap() { + setState(() => _showMap = !_showMap); + } + + void _onManualControl() { + setState(() => _mode = PvViewMode.manualControl); + } + + void _onCollapseManual() { + setState(() => _mode = PvViewMode.normal); + } + + void _onPauseTask() { + CapsuleToast.show( + AppLocalizations.of(context).translate('pv_control.toast_paused'), + showCheck: false, + ); + } + + void _onEmergencyStop() { + // HUD 急停钮为“点一下急停 / 再点一下恢复”的切换态(按钮自身不携带 on/off)。 + _emergencyOn = !_emergencyOn; + _channel.sendEmergencyStop(_emergencyOn); + final tr = AppLocalizations.of(context); + CapsuleToast.show( + _emergencyOn + ? tr.translate('pv_control.toast_emergency_sent') + : tr.translate('pv_control.toast_emergency_released'), + showCheck: false, + ); + } + + /// 进入控制态:查询权限 + 启动控制循环(与远程遥控页生命周期一致)。 + void _startControl() { + if (_controlActive) return; + if (_cubit.state.targetDevice == null) { + debugPrint('❌ [PV遥控] targetDevice 为空,跳过控制循环与权限查询'); + return; + } + _controlActive = true; + // deactivate 会清除急停态,重新进入从“未急停”开始,与通道保持同步。 + _emergencyOn = false; + _channel.activate(); + } + + /// 退出控制态:归零、停循环、释放权限(通道内部完成,不阻塞 UI)。 + void _stopControl() { + if (!_controlActive) return; + _controlActive = false; + // dispose 不能 await,fire-and-forget;拿到释放结果后弹与远程遥控页一致的提示 + //(CapsuleToast 走根 Overlay,页面销毁瞬间也能显示);无设备未发起释放则不弹。 + _channel.deactivate().then((released) { + if (released == null || !mounted) return; + final tr = AppLocalizations.of(context); + CapsuleToast.show( + released + ? tr.translate('pv_control.toast_permission_released') + : tr.translate('pv_control.toast_permission_release_failed'), + showCheck: released, + ); + }); + } + + /// 监听「权限交接」:订阅 Cubit 状态流,仅当 `updateType=='permission_dialog'` + /// 且 `showPermissionRequestDialog` 由 false→true 的上升沿时弹一次确认框(防重复), + /// 逻辑与远程遥控页 `didChangeDependencies` 完全一致。 + void _initPermissionHandoverListener() { + _permissionSubscription = _cubit.stream.listen((state) { + // 设备状态更新(500ms)不触发弹窗,只重置记录。 + if (state.updateType != 'permission_dialog') { + if (!state.showPermissionRequestDialog) { + _lastPermissionDialogState = false; + } + return; + } + final wasFalseBefore = + _lastPermissionDialogState == null || + _lastPermissionDialogState == false; + final shouldShow = + state.showPermissionRequestDialog && + !_isShowingPermissionDialog && + wasFalseBefore; + if (mounted && shouldShow) { + _lastPermissionDialogState = true; + _isShowingPermissionDialog = true; + debugPrint('🔔 [PV遥控][权限交接] 检测到请求控制权,准备弹窗'); + Future.microtask(() { + _showPermissionDialog(state).then((_) { + _isShowingPermissionDialog = false; + _lastPermissionDialogState = false; + }); + }); + } else if (!state.showPermissionRequestDialog) { + _lastPermissionDialogState = false; + } + }); + } + + /// 权限交接弹窗:某端请求控制权 → 同意/拒绝 → `confirmPermissionResponse` + /// 发 TCP 响应,关闭前复位 `showPermissionRequestDialog`(与远程遥控页同款)。 + Future _showPermissionDialog(RemoteControlState state) async { + final tr = AppLocalizations.of(context); + final requestingDeviceId = + state.requestingDeviceId ?? + tr.translate('pv_control.perm_unknown_device'); + final requestingPlatform = + state.requestingPlatform ?? + tr.translate('pv_control.perm_unknown_platform'); + final dialogContent = + tr + .translate('pv_control.perm_request_body') + .replaceAll('{platform}', requestingPlatform) + .replaceAll('{deviceId}', requestingDeviceId); + final cubit = _cubit; + return showDialog( + context: context, + barrierDismissible: false, + // 深色遮罩:贴合本页深蓝科技风大屏(不透默认 Material 黑半透)。 + barrierColor: PvColors.bgDeepest.withValues(alpha: 0.55), + builder: (dialogContext) { + Future respond(bool agreed) async { + final targetDevice = cubit.state.targetDevice; + if (targetDevice != null) { + await cubit.confirmPermissionResponse( + targetDevice.deviceName, + 'app', + agreed, + ); + } else { + debugPrint('❌ [PV遥控][权限交接] targetDevice 为空,无法响应'); + } + // 先复位标志位再关闭,避免 pop 后上下文失效。 + cubit.resetPermissionCoolDown(); + cubit.togglePermissionDialog(false); + if (dialogContext.mounted && Navigator.canPop(dialogContext)) { + Navigator.pop(dialogContext); + } + } + + final title = AppLocalizations.of( + dialogContext, + ).translate('remote_control.permission_request_title'); + final refuseText = AppLocalizations.of( + dialogContext, + ).translate('remote_control.refuse'); + final agreeText = AppLocalizations.of( + dialogContext, + ).translate('remote_control.agree'); + // 样式适配本页大屏:深蓝卡片 + 青色描边 + 科技青主按钮(与远程 + // 遥控页同一交互/文案,仅视觉风格跟随 pv 主题)。 + return Dialog( + backgroundColor: PvColors.card, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: PvColors.cardBorder, width: 1), + ), + insetPadding: const EdgeInsets.symmetric( + horizontal: 60, + vertical: 24, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 20, 22, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.swap_horiz, + color: PvColors.accent, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: PvType.cardTitle.copyWith(fontSize: 16), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + dialogContent, + style: PvType.body.copyWith(color: PvColors.textSecondary), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => respond(false), + child: Text( + refuseText, + style: PvType.body.copyWith( + color: PvColors.textSecondary, + ), + ), + ), + const SizedBox(width: 6), + TextButton( + onPressed: () => respond(true), + style: TextButton.styleFrom( + backgroundColor: PvColors.accent, + foregroundColor: PvColors.bgDeepest, + padding: const EdgeInsets.symmetric(horizontal: 18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + agreeText, + style: PvType.body.copyWith( + color: PvColors.bgDeepest, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + /// 设置面板(底部弹出,遮罩半透明可透视下方视频边调边看): + /// 视频源 / 视频展示 / 模拟障碍物信息 / 倒车影像。 + void _openSettings() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + // 面板内部自带 maxHeight + 滚动,这里放开模态默认高度限制 + isScrollControlled: true, + // 薄遮罩:能看见底下视频与告警/倒车效果的变化 + barrierColor: Colors.black.withValues(alpha: 0.35), + builder: (_) { + // 模态挂在 Overlay 上,页面 setState 不会刷新它, + // 用 StatefulBuilder 让药丸/开关的选中态即时回显。 + return StatefulBuilder( + builder: (sheetCtx, setSheetState) { + void update(void Function() fn) { + setState(fn); // 页面状态:视频链路 / 成套特效开关立即生效 + setSheetState(() {}); // 面板选中态回显 + } + + return PvSettingsSheet( + videoSource: _videoSource, + videoFit: _videoFit, + simObstacle: _simObstacle, + simReverse: _simReverse, + onVideoSourceChanged: (v) => update(() => _videoSource = v), + onVideoFitChanged: (v) => update(() => _videoFit = v), + onSimObstacleChanged: (v) => update(() => _simObstacle = v), + onSimReverseChanged: (v) => update(() => _simReverse = v), + ); + }, + ); + }, + ); + } + + Future _onScreenshot() async { + final bytes = await _captureVideoBytes(); + if (bytes == null) { + if (mounted) { + CapsuleToast.show( + AppLocalizations.of( + context, + ).translate('pv_control.toast_screenshot_failed'), + showCheck: false, + ); + } + return; + } + final path = await _saveToAppDir(bytes); + if (!mounted) return; + if (path != null) { + CapsuleToast.show( + AppLocalizations.of(context).translate('pv_control.toast_screenshot_saved'), + ); + } + // 截图后弹出预览 + await showDialog( + context: context, + builder: (ctx) => _ScreenshotPreviewDialog(imageBytes: bytes, path: path), + ); + } + + /// 截取视频区像素 + Future _captureVideoBytes() async { + try { + final boundary = + _videoKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return null; + final ui.Image image = await boundary.toImage(pixelRatio: 2.0); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + image.dispose(); + return byteData?.buffer.asUint8List(); + } catch (e) { + debugPrint('❌ [PvControl] 截图异常: $e'); + return null; + } + } + + /// 保存到应用文档目录(无需相册权限,稳定可用) + Future _saveToAppDir(Uint8List bytes) async { + try { + final dir = await getApplicationDocumentsDirectory(); + final ts = DateTime.now().millisecondsSinceEpoch; + final file = File('${dir.path}/pv_capture_$ts.png'); + await file.writeAsBytes(bytes); + return file.path; + } catch (e) { + debugPrint('❌ [PvControl] 保存截图失败: $e'); + return null; + } + } + + /// 按当前视频链路挂载对应的 cubit:页面级**单实例**,普通态与全屏 HUD 共用 + /// (RTSP/UVC 都是独占硬件会话,禁止双实例)。新增链路在这里注册一个分支。 + Widget _wrapVideoProvider(Widget child) { + switch (_videoSource) { + case PvVideoSource.usbCamera: + return BlocProvider( + // key:视频源切换时强制重建 cubit(新 UVC 会话 / 重新发现拉流) + key: const ValueKey('videoProvider_usbCamera'), + create: (_) => UsbCameraCubit(UvcCameraService())..start(), + child: child, + ); + case PvVideoSource.localRtsp: + return BlocProvider( + key: const ValueKey('videoProvider_localRtsp'), + // autoPlayFirst=发现后自动拉第一路(不展示步骤), + // autoRecover=缓冲卡死(0%)/断流时自动重拉流。 + create: (_) => + LocalCameraCubit(autoPlayFirst: true, autoRecover: true)..start(), + child: child, + ); + case PvVideoSource.webrtc: + return child; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: PvColors.bgDeepest, + body: DecoratedBox( + decoration: const BoxDecoration(gradient: PvColors.pageGradient), + child: SafeArea( + // 左上只是圆形摄像头挖孔,无实质内容,直接占用: + // 去掉左右安全区内边距,内容铺满整宽、在物理屏幕正中,不再被挖孔顶出一段空白。 + left: false, + right: false, + child: _wrapVideoProvider( + BlocBuilder( + builder: (context, dsState) { + final telemetry = _resolveTelemetry(dsState); + return _mode == PvViewMode.manualControl + ? PvManualControlView( + streamUrl: _streamUrl, + telemetry: telemetry, + onCollapse: _onCollapseManual, + onEmergencyStop: _onEmergencyStop, + videoSource: _videoSource, + videoFit: _videoFit, + simReverse: _simReverse, + deviceSn: widget.deviceId, + onSettings: _openSettings, + channel: _channel, + ) + : _buildNormalLayout(telemetry); + }, + ), + ), + ), + ), + ); + } + + Widget _buildNormalLayout(PvTelemetry telemetry) { + // ===== 三行高度比例:顶部导航 : 第一行(视频+右列) : 第二行(控制栏) ===== + // 三行高度比例:顶部导航 : 第一行 : 第二行。Expanded 的 flex 只能是整数, + // 这里写成 2 : 14 : 6(即 1 : 7 : 3)。第二行比 2.5 份再多一点, + // 好让较大的急停圆钮 + 文字竖着也能放得下,不再底部溢出。想要更矮就把 6 调回 5。 + const navFlex = 2; + const rowFlex = 14; + const barFlex = 6; + // ================================================================= + return Column( + children: [ + // ① 顶部导航 + Expanded( + flex: navFlex, + child: _PvTopNav( + onBack: () => Navigator.of(context).pop(), + onSettings: _openSettings, + ), + ), + // ② 第一行:实时视频 + 右侧列(信息 / 地图) + Expanded( + flex: rowFlex, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 6), + child: LayoutBuilder( + builder: (context, constraints) { + // 右侧列宽度:约占 34%,并限制在 260~460 之间 + final rightWidth = (constraints.maxWidth * 0.34).clamp( + 260.0, + 460.0, + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 第一行左:实时视频(可截图) + Expanded( + child: RepaintBoundary( + key: _videoKey, + child: PvVideoArea( + streamUrl: _streamUrl, + telemetry: telemetry, + onScreenshot: _onScreenshot, + onFullscreen: _onManualControl, + videoSource: _videoSource, + videoFit: _videoFit, + ), + ), + ), + const SizedBox(width: 8), + // 第一行右:信息列 / 地图(可切换) + SizedBox( + width: rightWidth, + child: _showMap + ? PvWorkMap(deviceSn: widget.deviceId) + : PvInfoColumn(telemetry: telemetry), + ), + ], + ); + }, + ), + ), + ), + // ③ 第二行:控制栏 + Expanded( + flex: barFlex, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 10), + child: PvControlBar( + onManualControl: _onManualControl, + onPauseTask: _onPauseTask, + onEmergencyStop: _onEmergencyStop, + onScreenshot: _onScreenshot, + onViewMap: _onViewMap, + mapActive: _showMap, + ), + ), + ), + ], + ); + } +} + +/// 顶部导航栏:左侧返回、中间标题/副标题、右侧设置与更多 +class _PvTopNav extends StatelessWidget { + const _PvTopNav({required this.onBack, required this.onSettings}); + final VoidCallback onBack; + final VoidCallback onSettings; + + @override + Widget build(BuildContext context) { + // 不再固定高度,随 _buildNormalLayout 中 navFlex 分配到的带状高度自动撑满, + // Row 默认垂直居中,内容在带内居中显示。 + return Row( + children: [ + IconButton( + onPressed: onBack, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon( + Icons.chevron_left, + color: PvColors.textPrimary, + size: 26, + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppLocalizations.of( + context, + ).translate('pv_control.realtime_video'), + style: PvType.navTitle, + ), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.pv_robot_remote_control'), + style: PvType.navSubtitle, + ), + ], + ), + ), + IconButton( + onPressed: onSettings, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon( + Icons.settings, + color: PvColors.textSecondary, + size: 20, + ), + ), + IconButton( + onPressed: () {}, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon( + Icons.more_horiz, + color: PvColors.textSecondary, + size: 20, + ), + ), + const SizedBox(width: 4), + ], + ); + } +} + +/// 截图预览弹窗 +class _ScreenshotPreviewDialog extends StatelessWidget { + const _ScreenshotPreviewDialog({required this.imageBytes, this.path}); + final Uint8List imageBytes; + final String? path; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: PvColors.card, + insetPadding: const EdgeInsets.all(40), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: InteractiveViewer( + child: Image.memory(imageBytes, fit: BoxFit.contain), + ), + ), + if (path != null) ...[ + const SizedBox(height: 10), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.screenshot_saved_path').replaceAll( + '{path}', + path!, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: PvType.label, + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + AppLocalizations.of( + context, + ).translate('pv_control.close'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/theme/pv_colors.dart b/lib/features/pv_control/presentation/theme/pv_colors.dart new file mode 100644 index 00000000..ab242918 --- /dev/null +++ b/lib/features/pv_control/presentation/theme/pv_colors.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +/// 光伏电站机器人远程控制横屏页专用配色(深蓝科技风大屏风格) +/// +/// 该页面是独立的横屏大屏可视化界面,视觉风格与常规浅色/深色 [AppColors] +/// 完全不同,因此单独维护一套固定配色,不随系统主题切换。 +/// 颜色取自设计稿:深蓝渐变底 + 青色描边发光 + 语义状态色。 +class PvColors { + PvColors._(); + + // ===== 背景 ===== + /// 页面底色(最深,接近黑蓝) + static const Color bgDeepest = Color(0xFF040A16); + + /// 渐变起始(左上) + static const Color bgGradientTop = Color(0xFF0A1A30); + + /// 渐变结束(右下) + static const Color bgGradientBottom = Color(0xFF050C1A); + + // ===== 卡片 / 面板 ===== + /// 卡片填充(半透明蓝) + static const Color card = Color(0xE6102338); + + /// 卡片内嵌块填充 + static const Color cardInner = Color(0x331E3A5F); + + /// 卡片描边(青色微光) + static const Color cardBorder = Color(0x662FB6E0); + + // ===== 强调 / 主色 ===== + /// 科技青主色 + static const Color accent = Color(0xFF22D3EE); + + /// 亮青(图标高亮) + static const Color accentBright = Color(0xFF5FEBFF); + + // ===== 文字 ===== + static const Color textPrimary = Color(0xFFF2F7FC); + static const Color textSecondary = Color(0xFF9DB2C9); + static const Color textTertiary = Color(0xFF6A819B); + + // ===== 语义状态色 ===== + /// 危险 / 紧急停止 / 障碍物预警 + static const Color danger = Color(0xFFFF3B4E); + + /// 在线 / 成功 + static const Color online = Color(0xFF22E0A0); + + /// 警告 + static const Color warning = Color(0xFFFFA940); + + /// 信息蓝(速度、温度等仪表) + static const Color info = Color(0xFF3FA9FF); + + /// 页面背景渐变 + static const LinearGradient pageGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [bgGradientTop, bgGradientBottom], + ); + + /// 标准卡片装饰:半透明蓝底 + 青色描边 + 圆角 + static BoxDecoration panel({ + double radius = 14, + Color? fill, + Color? border, + bool glow = false, + }) { + return BoxDecoration( + color: fill ?? card, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: border ?? cardBorder, width: 1), + boxShadow: glow + ? [ + BoxShadow( + color: accent.withValues(alpha: 0.18), + blurRadius: 16, + spreadRadius: 1, + ), + ] + : null, + ); + } +} + +/// 统一排版规范:收敛字号阶梯与行高,避免各组件各写各的导致视觉不和谐。 +/// +/// 所有 pv_control 组件的文本都应引用这里的样式,需要变色时用 `copyWith(color: ...)`。 +class PvType { + PvType._(); + + /// 统一行高倍数,让多行文本的纵向节奏一致。 + static const double lh = 1.2; + + /// 顶部导航主标题 + static const TextStyle navTitle = TextStyle( + color: PvColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + height: lh, + ); + + /// 顶部导航副标题 + static const TextStyle navSubtitle = TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + letterSpacing: 1, + height: lh, + ); + + /// 卡片标题(设备名 / 驾驶模式 / 浮层标题) + static const TextStyle cardTitle = TextStyle( + color: PvColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + height: lh, + ); + + /// 仪表数值(速度 / 里程 / 温度) + static const TextStyle metricValue = TextStyle( + color: PvColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + height: lh, + ); + + /// 文本型数值(网络“正常”等短词) + static const TextStyle metricText = TextStyle( + color: PvColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + height: lh, + ); + + /// 正文 + static const TextStyle body = TextStyle( + color: PvColors.textPrimary, + fontSize: 12, + height: lh, + ); + + /// 次要标签 / 说明 + static const TextStyle label = TextStyle( + color: PvColors.textSecondary, + fontSize: 11, + height: lh, + ); + + /// 更小的辅助文字(单位 / 角标 / 徽标) + static const TextStyle caption = TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + height: lh, + ); +} + +/// 统一间距刻度,供各组件纵向 / 横向留白引用,保证节奏和谐。 +class PvGap { + PvGap._(); + + static const double xs = 4; + static const double sm = 6; + static const double md = 8; + static const double lg = 10; + static const double xl = 14; +} diff --git a/lib/features/pv_control/presentation/widgets/pv_control_bar.dart b/lib/features/pv_control/presentation/widgets/pv_control_bar.dart new file mode 100644 index 00000000..7f8b474e --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_control_bar.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../theme/pv_colors.dart'; + +/// 第二行控制栏:手动控制 / 暂停任务 / 紧急停止 / 截图 / 查看地图。 +/// +/// 中间「紧急停止」为主操作,尺寸更大、红色发光;其余为普通圆形按钮。 +class PvControlBar extends StatelessWidget { + const PvControlBar({ + super.key, + required this.onManualControl, + required this.onPauseTask, + required this.onEmergencyStop, + required this.onScreenshot, + required this.onViewMap, + this.mapActive = false, + }); + + final VoidCallback onManualControl; + final VoidCallback onPauseTask; + final VoidCallback onEmergencyStop; + final VoidCallback onScreenshot; + final VoidCallback onViewMap; + + /// 「查看地图」处于激活态时高亮该按钮 + final bool mapActive; + + @override + Widget build(BuildContext context) { + final tr = AppLocalizations.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: PvColors.panel(radius: 18, border: PvColors.cardBorder), + child: Row( + children: [ + Expanded( + child: _ControlButton( + icon: Icons.adjust, + label: tr.translate('pv_control.manual_control'), + onTap: onManualControl, + ), + ), + Expanded( + child: _ControlButton( + icon: Icons.pause, + label: tr.translate('pv_control.pause_task'), + onTap: onPauseTask, + ), + ), + Expanded( + child: PvEmergencyStopButton(onTap: onEmergencyStop), + ), + Expanded( + child: _ControlButton( + icon: Icons.videocam, + label: tr.translate('pv_control.screenshot'), + onTap: onScreenshot, + ), + ), + Expanded( + child: _ControlButton( + icon: Icons.place, + label: tr.translate('pv_control.view_map'), + onTap: onViewMap, + active: mapActive, + ), + ), + ], + ), + ); + } +} + +class _ControlButton extends StatelessWidget { + const _ControlButton({ + required this.icon, + required this.label, + required this.onTap, + this.active = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + + @override + Widget build(BuildContext context) { + final color = active ? PvColors.accentBright : PvColors.textPrimary; + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? PvColors.accent.withValues(alpha: 0.18) + : PvColors.cardInner, + border: Border.all( + color: active ? PvColors.accent : PvColors.cardBorder, + width: active ? 2 : 1, + ), + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + textAlign: TextAlign.center, + style: TextStyle( + color: active + ? PvColors.accentBright + : PvColors.textSecondary, + fontSize: 10, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 紧急停止圆形按钮:红色发光 + 图标 + 下方文字。 +/// +/// 默认尺寸用于第二行控制栏;手动控制全屏态可传入更大的 [size] 复用同一个按钮。 +class PvEmergencyStopButton extends StatefulWidget { + const PvEmergencyStopButton({ + super.key, + required this.onTap, + this.size = 46, + this.iconSize = 26, + this.gap = 2, + this.labelSize = 10, + }); + final VoidCallback onTap; + final double size; + final double iconSize; + final double gap; + final double labelSize; + + @override + State createState() => _PvEmergencyStopButtonState(); +} + +class _PvEmergencyStopButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + /// 是否处于「急停中」:默认 false(正常态、不呼吸)。 + /// 点击一次进入急停(呼吸 + 「急停中」提示),再点一次解除恢复正常。 + bool _active = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTap() { + setState(() => _active = !_active); + if (_active) { + _controller.repeat(reverse: true); + } else { + _controller.stop(); + _controller.value = 0; + } + widget.onTap(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _active ? _controller.value : 0.0; + return GestureDetector( + onTap: _handleTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Transform.scale( + scale: 1.0 + 0.08 * t, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const RadialGradient( + colors: [Color(0xFFFF5A4D), Color(0xFFD6000F)], + ), + boxShadow: [ + BoxShadow( + color: PvColors.danger.withValues(alpha: 0.5 + 0.35 * t), + blurRadius: 18 + 12 * t, + spreadRadius: 2 + 3 * t, + ), + ], + ), + child: Icon( + Icons.stop_circle_outlined, + color: Colors.white, + size: widget.iconSize, + ), + ), + ), + SizedBox(height: widget.gap), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + AppLocalizations.of(context).translate( + _active + ? 'pv_control.emergency_stopping' + : 'pv_control.emergency_stop', + ), + maxLines: 1, + textAlign: TextAlign.center, + style: TextStyle( + color: _active ? PvColors.danger : PvColors.textPrimary, + fontSize: widget.labelSize, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_info_column.dart b/lib/features/pv_control/presentation/widgets/pv_info_column.dart new file mode 100644 index 00000000..90b0a1bb --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_info_column.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; + +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../models/pv_telemetry.dart'; +import '../theme/pv_colors.dart'; + +/// 第一行右侧信息列:设备概览 / 驾驶模式 / 速度·里程 / 网络·温度。 +class PvInfoColumn extends StatelessWidget { + const PvInfoColumn({ + super.key, + required this.telemetry, + this.onSwitchMode, + }); + + final PvTelemetry telemetry; + final VoidCallback? onSwitchMode; + + @override + Widget build(BuildContext context) { + // 收窄上下间距 + 卡片内边距,尽量让全部内容在一屏内可见(不触发滚动); + // 仍保留 SingleChildScrollView 作为极小屏的安全兜底。 + return SingleChildScrollView( + child: Column( + children: [ + _RobotHeader(telemetry: telemetry), + const SizedBox(height: 6), + _DriveModeCard( + telemetry: telemetry, + onSwitchMode: onSwitchMode, + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded(child: _MetricCard.icon( + icon: Icons.speed, + color: PvColors.info, + label: AppLocalizations.of( + context, + ).translate('pv_control.current_speed'), + value: telemetry.speed.toStringAsFixed(1), + unit: 'm/s', + )), + const SizedBox(width: 6), + Expanded(child: _MetricCard.icon( + icon: Icons.timeline, + color: PvColors.accent, + label: AppLocalizations.of( + context, + ).translate('pv_control.patrol_mileage'), + value: telemetry.mileage == null + ? AppLocalizations.of(context).translate( + 'pv_control.unknown', + ) + : telemetry.mileage!.toStringAsFixed(1), + unit: telemetry.mileage == null ? '' : 'km', + )), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded(child: _MetricCard.icon( + icon: Icons.signal_cellular_alt, + color: PvColors.online, + label: AppLocalizations.of( + context, + ).translate('pv_control.network_status'), + value: telemetry.networkNormal + ? AppLocalizations.of(context).translate( + 'pv_control.status_normal', + ) + : AppLocalizations.of(context).translate( + 'pv_control.status_abnormal', + ), + unit: '', + valueIsText: true, + )), + const SizedBox(width: 6), + Expanded(child: _MetricCard.icon( + icon: Icons.thermostat, + color: PvColors.info, + label: AppLocalizations.of( + context, + ).translate('pv_control.device_temperature'), + value: telemetry.temperature == null + ? AppLocalizations.of(context).translate( + 'pv_control.unknown', + ) + : telemetry.temperature!.toStringAsFixed(1), + unit: telemetry.temperature == null ? '' : '℃', + )), + ], + ), + ], + ), + ); + } +} + +class _RobotHeader extends StatelessWidget { + const _RobotHeader({required this.telemetry}); + final PvTelemetry telemetry; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: PvColors.panel(), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: PvColors.cardInner, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.smart_toy_outlined, + color: PvColors.accentBright, + size: 22, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + telemetry.deviceName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: PvColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + _OnlineBadge(isOnline: telemetry.isOnline), + ], + ), + const SizedBox(height: 3), + Row( + children: [ + Text( + 'SN:${telemetry.serialNumber}', + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + ), + ), + const SizedBox(width: 6), + Icon( + Icons.battery_std, + size: 12, + color: telemetry.battery == null + ? PvColors.textTertiary + : (telemetry.battery! > 20 + ? PvColors.online + : PvColors.danger), + ), + Text( + telemetry.battery == null + ? AppLocalizations.of(context).translate( + 'pv_control.battery_unknown', + ) + : AppLocalizations.of(context).translate( + 'pv_control.battery_percent', + ).replaceAll( + '{n}', + telemetry.battery!.toStringAsFixed(0), + ), + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _OnlineBadge extends StatelessWidget { + const _OnlineBadge({required this.isOnline}); + final bool isOnline; + + @override + Widget build(BuildContext context) { + final color = isOnline ? PvColors.online : PvColors.textTertiary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.6)), + ), + child: Text( + isOnline + ? AppLocalizations.of(context).translate('pv_control.online') + : AppLocalizations.of(context).translate('pv_control.offline'), + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w700), + ), + ); + } +} + +class _DriveModeCard extends StatelessWidget { + const _DriveModeCard({required this.telemetry, this.onSwitchMode}); + final PvTelemetry telemetry; + final VoidCallback? onSwitchMode; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: PvColors.panel(), + child: Row( + children: [ + const Icon( + Icons.local_shipping_outlined, + color: PvColors.accent, + size: 22, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + telemetry.isAutoDriving + ? AppLocalizations.of(context).translate( + 'pv_control.auto_driving', + ) + : AppLocalizations.of(context).translate( + 'pv_control.manual_controlling', + ), + style: const TextStyle( + color: PvColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 1), + Text( + telemetry.taskStatus, + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + ), + ), + ], + ), + ), + GestureDetector( + onTap: onSwitchMode, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: PvColors.cardInner, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: PvColors.cardBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context).translate( + 'pv_control.switch_mode', + ), + style: TextStyle( + color: PvColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 4), + Icon(Icons.swap_horiz, size: 14, color: PvColors.accent), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard.icon({ + required this.icon, + required this.color, + required this.label, + required this.value, + required this.unit, + this.valueIsText = false, + }); + + final IconData icon; + final Color color; + final String label; + final String value; + final String unit; + final bool valueIsText; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: PvColors.panel(), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + ), + ), + const SizedBox(height: 1), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + child: Text( + value, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: PvColors.textPrimary, + fontSize: valueIsText ? 12 : 14, + fontWeight: FontWeight.w800, + ), + ), + ), + if (unit.isNotEmpty) ...[ + const SizedBox(width: 3), + Text( + unit, + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 10, + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_local_rtsp_video.dart b/lib/features/pv_control/presentation/widgets/pv_local_rtsp_video.dart new file mode 100644 index 00000000..49416bd5 --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_local_rtsp_video.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:video_player/video_player.dart'; + +import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/cubit/local_camera_state.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../theme/pv_colors.dart'; + +/// 本地视频(以太网直连摄像头 RTSP / ExoPlayer)视图。 +/// +/// 实验用视频源:复用 [LocalCameraCubit] 的完整链路(网络检查 → ONVIF 发现 → +/// 取 RTSP 地址 → video_player 拉流),cubit 需以 `autoPlayFirst + autoRecover` +/// 创建 —— 全程**自动获取**,画面出来前只显示一行简短状态,链条步骤不展示; +/// 卡顿(缓冲进度 0% 超时)/断流由 cubit 自动重拉流恢复。 +/// 上层必须已挂好 `BlocProvider`(pv_control_page 负责)。 +class PvLocalRtspVideo extends StatelessWidget { + const PvLocalRtspVideo({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final controller = context.read().videoController; + Widget layer; + if (controller != null && controller.value.isInitialized) { + // 部分摄像头 SDP 不带分辨率,size 为 0×0 时 aspectRatio 会崩成 + // 纯黑(与本地视频页同款兜底):回退 16:9。 + final size = controller.value.size; + final ratio = size.width > 0 && size.height > 0 + ? size.width / size.height + : 16 / 9; + layer = Center( + child: AspectRatio( + aspectRatio: ratio, + child: VideoPlayer(controller), + ), + ); + } else { + layer = const ColoredBox(color: Colors.black); + } + return Stack( + fit: StackFit.expand, + children: [ + layer, + // 非播放态统一盖一层简短状态(不展示链条步骤) + if (state.phase != LocalCameraPhase.playing) + Positioned.fill(child: _statusLayer(context, state)), + ], + ); + }, + ); + } + + Widget _statusLayer(BuildContext context, LocalCameraState state) { + final tr = AppLocalizations.of(context); + String text; + var busy = true; + switch (state.phase) { + case LocalCameraPhase.idle: + case LocalCameraPhase.searching: + text = tr.translate('pv_control.rtsp_searching'); + break; + case LocalCameraPhase.found: + case LocalCameraPhase.connecting: + text = tr.translate('pv_control.rtsp_connecting'); + break; + case LocalCameraPhase.disconnected: + text = tr.translate('pv_control.rtsp_reconnecting'); + break; + case LocalCameraPhase.notFound: + text = tr.translate('pv_control.rtsp_not_found'); + break; + case LocalCameraPhase.playFailed: + text = tr.translate('pv_control.rtsp_failed'); + break; + case LocalCameraPhase.noNetwork: + text = tr.translate('pv_control.rtsp_no_ethernet'); + busy = false; + break; + case LocalCameraPhase.playing: + text = ''; + break; + } + return Container( + color: Colors.black.withValues(alpha: busy ? 0.55 : 0.85), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (busy) + const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: PvColors.accentBright, + ), + ) + else + const Icon(Icons.info_outline, + color: Colors.white38, size: 26), + const SizedBox(height: 10), + Text( + text, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart b/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart new file mode 100644 index 00000000..9e62e8e7 --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart @@ -0,0 +1,1963 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:cc_ui_kit/cc_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/remote_control/domain/entities/reverse_camera_data.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/reverse_guide_overlay.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart'; + +import '../models/pv_telemetry.dart'; +import '../models/pv_video_fit.dart'; +import '../models/pv_video_source.dart'; +import '../theme/pv_colors.dart'; +import '../channel/pv_robot_command_channel.dart'; +import 'pv_control_bar.dart'; +import 'pv_rear_view_card.dart'; +import 'pv_local_rtsp_video.dart'; +import 'pv_task_dialog.dart'; +import 'pv_usb_camera_video.dart'; +import 'pv_video_fit_box.dart'; +import 'pv_work_map.dart'; + +/// 操作浮层规格:标题、用法提示、三段图标(上 / 中 / 下)与图标回正段数。 +/// +/// [turns]:火焰系列素材内部已经预旋过 90°(远程遥控顶栏是横着放的), +/// 浮层里竖着放要传 1 转回正;箭头 / 剪刀 / 图层这类本来就正立的传 0。 +typedef _PvSheetSpec = ({ + String title, + String hint, + String up, + String mid, + String down, + String segUp, + String segMid, + String segDown, + int turns, +}); + +/// 遥控模式全屏 HUD(替换原极简手动控制视图)。 +/// +/// 设计稿为平板比例(约 1.9:1),本视图整体「scale 驱动」:以参考高度 560dp +/// 计算缩放系数 [_sc],手机横屏(高约 360 → _sc≈0.64)时顶栏占比、字号、按钮 +/// 全部等比缩小,平板(高约 600 → _sc≈1.07)时放大,从而在两种设备上还原版式。 +/// +/// AR 障碍框、遥测数值为写死的假数据;控制指令(摇杆 / 割刀 / 底盘 / 点火 / 急停) +/// 已通过 [PvRobotCommandChannel] 接入(TCP 实现委托远程遥控 Cubit,见 pv_robot_command_channel.dart)。 +class PvManualControlView extends StatefulWidget { + const PvManualControlView({ + super.key, + required this.streamUrl, + required this.telemetry, + required this.onCollapse, + required this.onEmergencyStop, + this.videoSource = PvVideoSource.webrtc, + this.videoFit = PvVideoFit.contain, + this.simReverse = false, + this.deviceSn = '', + this.onSettings, + this.channel, + }); + + final String streamUrl; + final PvTelemetry telemetry; + final VoidCallback onCollapse; + final VoidCallback onEmergencyStop; + + /// 视频链路(多源可扩展):现在 pv 页传入 usbCamera。 + /// cubit 由页面层按链路单实例提供(pv_control_page._wrapVideoProvider)。 + final PvVideoSource videoSource; + + /// 视频展示方式(设置面板可改):contain 完整显示 / cover 等比铺满(裁边)。 + final PvVideoFit videoFit; + + /// 倒车影像模拟开关(设置面板控制):开启后左栏后视卡叠加倒车引导线。 + final bool simReverse; + + /// 设备 SN:HUD 地图按它过滤后端推送的规划路线(订阅切换由页面层负责)。 + final String deviceSn; + + /// 顶栏「设置」按钮回调(弹出设置面板)。 + final VoidCallback? onSettings; + + /// 指令传输通道(TCP / 未来蓝牙等,由页面注入):摇杆、割刀 / 底盘 / 点火、 + /// 急停、回中经它下发。为空时相关操作退化为无副作用(仅演示场景)。 + final PvRobotCommandChannel? channel; + + @override + State createState() => _PvManualControlViewState(); +} + +class _PvManualControlViewState extends State + with TickerProviderStateMixin { + // 注意:本 State 同时持有 _pulse / _flow 两个 AnimationController, + // 必须用 TickerProviderStateMixin(SingleTicker 只允许一个 ticker)。 + /// 缩放系数:build 时按当前高度算出,供所有尺寸 / 字号引用。 + double _sc = 1; + + /// 障碍物预警横幅脉动动画。 + late final AnimationController _pulse; + + /// 绿色导向箭头的“递进流动”动画(单向循环)。 + late final AnimationController _flow; + + /// 倒车引导线的实时转向角(度,-45~45):当前由右摇杆驱动, + /// 用 ValueNotifier 只刷新后视卡覆盖层,拖摇杆时不重建整个 HUD。 + /// 将来下位机给转向角后,只需把这个值的赋值来源换成嵌入式字段(改一处即可)。 + final ValueNotifier _steerDeg = ValueNotifier(0); + + /// 左 / 右两侧面板是否已折叠。 + bool _leftCollapsed = false; + bool _rightCollapsed = false; + + /// 顶栏「视频」开关:显示左右视角浮窗(同时把两侧面板折叠)。 + bool _showSidePips = false; + + /// 顶栏「地图」开关:左栏地图卡放大到屏幕正中央(同时把两侧面板折叠)。 + bool _showBigMap = false; + + /// 进入聚焦模式(浮窗 / 大地图)前的折叠状态,两种聚焦都关掉才还原。 + ({bool left, bool right})? _foldBackup; + + /// 底部操作条里已点亮的按钮名(点一下开、再点关): + /// 点亮后的底色 / 外发光跟远程遥控的开关按钮同一套配色。 + final Set _barActive = {}; + + /// 当前「长按拖动中」的按钮:'mower' | 'chassis' | 'fire' | null(一次只有一个)。 + /// 手指按住底部按钮直接上下拖即切段,屏幕中央只显示读数气泡、不遮挡操作。 + String? _sheetKey; + + /// 拖动时当前所处的段位:1=上、0=中(停)、2=下。 + int _scrubSeg = 0; + + /// 松手后短暂保留读数气泡(显示「停」)再隐藏的计时。 + Timer? _sheetCloseTimer; + + /// 长按拖动期间的 100ms 心跳:跟远程遥控 `CCExpandSlider._startTicking` 同款。 + /// 底盘 / 割刀 / 点火需要持续指令才会持续动作,故按住不动也每 100ms 重发当前 + /// 段位,而不是只在越界换段那一刻发一次(之前“半天才一个”就是这个区别)。 + Timer? _scrubTimer; + + /// 顶栏实时时钟(HH:mm:ss):每秒刷新,显示真实当前时间而非固定值。 + Timer? _clockTimer; + String _clockText = ''; + + /// 浮窗拖拽位置(null = 首次显示时自动靠左右两侧摆放)。 + Offset? _leftPipPos; + Offset? _rightPipPos; + + /// 折叠动画时长与曲线(两侧共用,保证节奏一致)。 + static const Duration _foldDur = Duration(milliseconds: 280); + static const Curve _foldCurve = Curves.easeInOutCubic; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 750), + )..repeat(reverse: true); + _flow = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(); + // 顶栏时钟:先取当前时间,随后每秒刷新(真实时间,非固定值)。 + _clockText = _formatClock(); + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _clockText = _formatClock()); + }); + } + + String _formatClock() { + final n = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(n.hour)}:${two(n.minute)}:${two(n.second)}'; + } + + /// 定位质量枚举→文案(与设备监控弹窗/蓝牙协议同一套 fix_status 语义)。 + static String _qualText(int? q) => switch (q) { + null => 'pv_control.unknown', + 0 => 'pv_control.qual_none', + 1 => 'pv_control.qual_single', + 2 => 'pv_control.qual_differential', + 3 => 'pv_control.qual_ins', + 4 => 'pv_control.qual_rtk_fixed', + 5 => 'pv_control.qual_rtk_float', + 8 => 'pv_control.qual_rtk_fixed_nh', + 9 => 'pv_control.qual_rtk_float_nh', + _ => 'pv_control.unknown', + }; + + /// 控制模式→文案(与“设备数据监控” _getControlModeKey 同口径:3=远程,其余=本地)。 + static String _controlModeText(String? mode) { + if (mode == null || mode.isEmpty) return 'pv_control.unknown'; + return int.tryParse(mode.trim()) == 3 + ? 'pv_control.mode_remote' + : 'pv_control.mode_local'; + } + + @override + void dispose() { + _sheetCloseTimer?.cancel(); + _scrubTimer?.cancel(); + _clockTimer?.cancel(); + _pulse.dispose(); + _flow.dispose(); + _steerDeg.dispose(); + super.dispose(); + } + + /// 设计像素 → 当前屏幕像素。 + double d(num v) => v * _sc; + + /// 基于 PvType 基准样式派生缩放后的文本样式(变色用 copyWith(color:))。 + TextStyle s(TextStyle base, double size) => base.copyWith(fontSize: d(size)); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, c) { + final w = c.maxWidth; + final h = c.maxHeight; + _sc = (h / 560).clamp(0.6, 1.2).toDouble(); + return Stack( + fit: StackFit.expand, + children: [ + // ① 视频铺底 + _buildVideo(), + // ② 假 AR 覆盖层(障碍框 + 导向箭头 + 预警横幅),不拦截手势 + _buildArOverlay(w, h), + // ③ 顶部状态栏 + _buildTopBar(w), + // ④ 左侧信息栈(可折叠) + _buildLeftColumn(w, h), + _buildLeftTab(w, h), + // ⑤ 右侧工具栏 + 急停(可折叠) + _buildRightRail(h), + _buildRightTab(h), + // ⑤⁺ 视频键:左右视角浮窗(叠在折叠后的面板之上,不挡摇杆) + _buildSidePips(w, h), + // ④⁺ 地图键:地图卡(小卡位置 ↔ 屏幕中央放大,同一张卡飞过去) + _buildMapPanel(w, h), + // ⑥ 双摇杆 + _buildJoysticks(w, h), + // ⑦ 底部操作条 + _buildBottomBar(w, h), + // ⑧ 拖动读数气泡(长按底部条的升降 / 点火按钮就地拖动时显示,不遮挡) + _buildScrubBubble(), + ], + ); + }, + ); + } + + // ===== ① 视频层 ===== + + /// 全屏底图:按「视频展示」档位做 contain / cover 适配。 + Widget _buildVideo() { + return PvVideoFitBox( + fit: widget.videoFit, + source: widget.videoSource, + child: _buildVideoLayer(), + ); + } + + /// 各链路的原始视频层(组件内部自己等比居中): + /// 左右视角浮窗要自己做边缘裁切,直接用这一层,不叠铺满适配。 + Widget _buildVideoLayer() { + if (widget.videoSource == PvVideoSource.usbCamera) { + return const PvUsbCameraVideo(); + } + if (widget.videoSource == PvVideoSource.localRtsp) { + return const PvLocalRtspVideo(); + } + return widget.streamUrl.isNotEmpty + ? WebRTCLocalPlayer( + streamUrl: widget.streamUrl, + showLeftPip: false, + showRightPip: false, + mainViewAlignment: Alignment.center, + ) + : Container( + color: Colors.black87, + child: const Center( + child: Icon(Icons.videocam_off, size: 48, color: Colors.white38), + ), + ); + } + + // ===== ② 假 AR 覆盖层 ===== + Widget _buildArOverlay(double w, double h) { + return Positioned.fill( + child: IgnorePointer( + child: Stack( + fit: StackFit.expand, + children: [ + // 导向路径 + 车道线(箭头随 _flow 递进流动;整条脊柱随右摇杆转向弯曲) + ValueListenableBuilder( + valueListenable: _steerDeg, + builder: (context, deg, _) => AnimatedBuilder( + animation: _flow, + builder: (context, _) => CustomPaint( + painter: _PvArPainter(progress: _flow.value, steering: deg), + ), + ), + ), + // 障碍物告警成套效果:收到机器推送(或设置面板模拟开启)时, + // 屏幕泛红呼吸 + 障碍框×2 + 预警横幅**同时**出现,同一 obstacleAlert 驱动。 + if (widget.telemetry.obstacleAlert) ...[ + // 屏幕四周泛红(与顶部横幅共用 _pulse,节奏同步) + AnimatedBuilder( + animation: _pulse, + builder: (context, _) => CustomPaint( + painter: _PvRedGlowPainter( + alpha: 0.35 + 0.65 * _pulse.value, + unit: _sc, + ), + ), + ), + // 障碍框(写死位置 + 距离) + _arBox( + left: w * 0.29, + top: h * 0.20, + width: d(120), + height: d(200), + label: '0.8m', + ), + _arBox( + right: w * 0.10, + top: h * 0.28, + width: d(210), + height: d(190), + label: '0.6m', + ), + // 顶部预警横幅(脉动) + Positioned( + left: 0, + right: 0, + top: d(58), + child: _buildArBanner(), + ), + ], + ], + ), + ), + ); + } + + Widget _buildArBanner() { + return AnimatedBuilder( + animation: _pulse, + builder: (context, child) { + final t = _pulse.value; + return Center( + child: Opacity( + opacity: 0.72 + 0.28 * t, + child: Transform.scale( + scale: 1.0 + 0.05 * t, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: d(22), + vertical: d(7), + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFE01E32), Color(0xFFFF5A4D)], + ), + borderRadius: BorderRadius.circular(d(8)), + boxShadow: [ + BoxShadow( + color: PvColors.danger.withValues(alpha: 0.3 + 0.4 * t), + blurRadius: 12 + 16 * t, + spreadRadius: 1 + 2 * t, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_rounded, + color: Colors.white, + size: d(20), + ), + SizedBox(width: d(8)), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.obstacle_ahead'), + style: s(PvType.cardTitle, 16).copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(width: d(12)), + Text( + '${widget.telemetry.obstacleDistance.toStringAsFixed(1)} m', + style: s( + PvType.metricText, + 16, + ).copyWith(color: Colors.white), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Widget _arBox({ + double? left, + double? right, + required double top, + required double width, + required double height, + required String label, + }) { + return Positioned( + left: left, + right: right, + top: top, + child: SizedBox( + width: width, + height: height, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(d(6)), + border: Border.all(color: PvColors.danger, width: d(2)), + boxShadow: [ + BoxShadow( + color: PvColors.danger.withValues(alpha: 0.45), + blurRadius: d(14), + ), + ], + ), + ), + ), + Positioned( + right: 0, + bottom: d(6), + child: Container( + padding: EdgeInsets.symmetric(horizontal: d(8), vertical: d(3)), + decoration: BoxDecoration( + color: PvColors.danger, + borderRadius: BorderRadius.circular(d(4)), + ), + child: Text( + label, + style: s( + PvType.body, + 12, + ).copyWith(color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + ); + } + + // ===== ③ 顶部状态栏 ===== + Widget _buildTopBar(double w) { + final tr = AppLocalizations.of(context); + return Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: d(52), + padding: EdgeInsets.symmetric(horizontal: d(12)), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xE6050C1A), Color(0x00050C1A)], + ), + ), + child: Row( + children: [ + // 左:返回 + 标题 + 在线 + _iconBtn(Icons.chevron_left, widget.onCollapse), + SizedBox(width: d(2)), + Text(tr.translate('pv_control.remote_mode'), style: s(PvType.navTitle, 17)), + SizedBox(width: d(8)), + _onlineBadge(), + SizedBox(width: d(8)), + // 「正在控制 / 未在控制」状态胶囊(与远程遥控顶栏同款、同逻辑) + _buildControlStatusChip(), + // 中:链路信息(FittedBox 保证窄屏自动缩小不溢出) + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: d(8)), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _chip( + Icons.signal_cellular_alt, + '4G', + PvColors.textPrimary, + ), + _chip(Icons.wifi, '', PvColors.accentBright), + _chip( + Icons.gps_fixed, + tr.translate(_qualText(widget.telemetry.fixStatus)), + widget.telemetry.fixStatus == 4 + ? PvColors.online + : PvColors.textSecondary, + ), + _chip( + Icons.satellite_alt, + widget.telemetry.satelliteCount?.toString() ?? + tr.translate('pv_control.unknown'), + PvColors.textPrimary, + ), + _chip( + Icons.battery_std, + widget.telemetry.battery == null + ? tr.translate('pv_control.unknown') + : '${widget.telemetry.battery!.toStringAsFixed(0)}%', + widget.telemetry.battery == null + ? PvColors.textTertiary + : (widget.telemetry.battery! > 20 + ? PvColors.online + : PvColors.danger), + ), + _chip(Icons.schedule, '5.6 h', PvColors.textSecondary), + Text(_clockText, style: s(PvType.body, 14)), + ], + ), + ), + ), + ), + // 右:导航(设置已接弹出面板,其余写死暂不跳转)整体左移并放大一点 + _navItem( + Icons.description, + tr.translate('pv_control.nav_task'), + onTap: () => PvTaskDialog.show(context), + ), + _navItem( + Icons.map_outlined, + tr.translate('pv_control.nav_map'), + active: _showBigMap, + onTap: _toggleBigMap, + ), + _navItem( + Icons.videocam, + tr.translate('pv_control.nav_video'), + active: _showSidePips, + onTap: _toggleSidePips, + ), + _navItem( + Icons.settings_outlined, + tr.translate('pv_control.nav_settings'), + onTap: widget.onSettings, + ), + SizedBox(width: d(16)), + ], + ), + ), + ); + } + + Widget _iconBtn(IconData icon, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Icon(icon, color: PvColors.textPrimary, size: d(28)), + ); + } + + Widget _onlineBadge() { + return Container( + padding: EdgeInsets.symmetric(horizontal: d(8), vertical: d(2)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(d(4)), + border: Border.all(color: PvColors.online), + ), + child: Text( + AppLocalizations.of(context).translate('pv_control.online'), + style: s( + PvType.caption, + 11, + ).copyWith(color: PvColors.online, fontWeight: FontWeight.w700), + ), + ); + } + + /// 「正在控制 / 未在控制」状态胶囊:与远程遥控顶栏 `TopStatusBar` 完全同款、同逻辑。 + /// + /// 直接监听 [RemoteControlCubit] 单例的 `hasPermission`:有权限=绿色「正在控制」不呼吸; + /// 无权限=红色「未在控制」呼吸,点击发起 `requestControlPermissionS(grabControl: true)` + /// 抢占控制权(发 TCP switch_control)。与原页一样走 cubit 本身,不经指令通道。 + Widget _buildControlStatusChip() { + final cubit = sl(); + return StreamBuilder( + initialData: cubit.state, + stream: cubit.stream, + builder: (context, snapshot) { + final hasPermission = snapshot.data?.hasPermission ?? false; + return StatusChip( + text: hasPermission + ? AppLocalizations.of( + context, + ).translate('remote_control.controlling') + : AppLocalizations.of( + context, + ).translate('remote_control.not_controlling'), + color: hasPermission ? const Color(0xFF1DB954) : Colors.red, + icon: Icons.eighteen_mp, + breathing: !hasPermission, + onTap: () { + // 已在控制:无需重复抢占。 + if (hasPermission) return; + final targetDevice = cubit.state.targetDevice; + if (targetDevice == null) { + debugPrint('❌ [PV遥控] targetDevice 为空,无法请求权限'); + return; + } + debugPrint('🔑 [PV遥控] 👆 点击“未在控制”,请求抢占控制权'); + cubit.requestControlPermissionS( + targetDevice.deviceName, + 'app', + source: '手动点击', + grabControl: true, + ); + }, + ); + }, + ); + } + + Widget _chip(IconData icon, String text, Color color) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: d(7)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: d(16)), + if (text.isNotEmpty) ...[ + SizedBox(width: d(3)), + Text(text, style: s(PvType.label, 12).copyWith(color: color)), + ], + ], + ), + ); + } + + Widget _navItem( + IconData icon, + String label, { + VoidCallback? onTap, + bool active = false, + }) { + final color = active ? PvColors.accentBright : PvColors.textPrimary; + return GestureDetector( + onTap: onTap ?? () => debugPrint('[PV遥控] 导航 $label(暂未接入)'), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: d(11)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: d(22)), + Text(label, style: s(PvType.caption, 12).copyWith(color: color)), + ], + ), + ), + ); + } + + // ===== ⑤⁺ 左右视角浮窗(顶栏「视频」开关)===== + + /// 进入聚焦模式:先记下当前折叠状态,再把两侧收起来(浮窗 / 大地图共用)。 + void _enterFocusMode() { + _foldBackup ??= (left: _leftCollapsed, right: _rightCollapsed); + _leftCollapsed = true; + _rightCollapsed = true; + } + + /// 退出聚焦模式:两种聚焦都不在了才还原,避免中途把状态恢复成“已折叠”。 + void _exitFocusMode() { + if (_showSidePips || _showBigMap) return; + final backup = _foldBackup; + if (backup == null) return; + _foldBackup = null; + _leftCollapsed = backup.left; + _rightCollapsed = backup.right; + } + + /// 点「视频」:两侧面板折叠 + 弹出左右视角浮窗;再点一次收起并还原原来的折叠状态。 + void _toggleSidePips() { + setState(() { + _showSidePips = !_showSidePips; + if (_showSidePips) { + // 与地图聚焦互斥(保留原始折叠状态,_enterFocusMode 里的 ??= 不会覆盖) + _showBigMap = false; + _enterFocusMode(); + // 重新打开回到默认位置 + _leftPipPos = null; + _rightPipPos = null; + } + _exitFocusMode(); + }); + } + + /// 点「地图」:两侧面板折叠 + 地图卡飞到屏幕中央放大;再点一次飞回左栏并还原折叠状态。 + void _toggleBigMap() { + setState(() { + _showBigMap = !_showBigMap; + if (_showBigMap) { + // 地图放大时不需要左右视角浮窗,两者互斥 + _showSidePips = false; + _enterFocusMode(); + } + _exitFocusMode(); + }); + } + + /// 地图卡停靠在左栏时的总高度(面板内边距 + 地图区 + 间隔 + 底部作业信息行)。 + /// 浮层尺寸与左栏占位共用此公式,保证两者严格对齐。 + double _mapDockH(double leftW) => leftW * 0.62 + d(38); + + /// 地图卡浮层:平时坐在左栏原位(rect 与左栏第一张卡一致,看起来就是栏内的一部分), + /// 点「地图」后用同一套 _foldDur / _foldCurve 把 rect 平滑动画到屏幕中央并放大。 + Widget _buildMapPanel(double w, double h) { + final leftW = _leftW(w); + final dockH = _mapDockH(leftW); + final bigW = (w * 0.46).clamp(d(240), d(560)); + // 上限先取「屏高 82%」与「避开顶/底栏」的较小值,避免 clamp 下界 > 上界触发断言 + final bigH = math.min(h * 0.82, h - d(70)).clamp(d(120), h); + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + // 收起时跟着左栏一起滑出屏幕(与面板折叠手感一致) + left: _showBigMap + ? (w - bigW) / 2 + : (_leftCollapsed ? -(leftW + d(14)) : d(10)), + top: _showBigMap ? (h - bigH) / 2 + d(6) : d(56), + width: _showBigMap ? bigW : leftW, + height: _showBigMap ? bigH : dockH, + child: _mapCard(), + ); + } + + Widget _buildSidePips(double w, double h) { + if (!_showSidePips) return const SizedBox.shrink(); + final tr = AppLocalizations.of(context); + // 窗体宽高比 = 源画面宽高比 ÷ 2(“左右半幅”的比例),再把画面按宽度放大 2 倍裁切: + // 二者抵消后等比不拉伸、不留黑边、也不丢掉任何画面内容。 + final cropRatio = pvFrameRatioOf(context, widget.videoSource) / 2; + var pipW = (w * 0.24).clamp(d(150), d(300)); + var pipH = pipW / cropRatio; + final maxH = h * 0.62; // 不让浮窗吃掉摇杆区域 + if (pipH > maxH) { + pipH = maxH; + pipW = pipH * cropRatio; + } + // 首次进入靠左右上角摆放(避开顶栏与底部操作条) + _leftPipPos ??= Offset(d(10), d(58)); + _rightPipPos ??= Offset(w - pipW - d(10), d(58)); + return Positioned.fill( + child: Stack( + children: [ + _sidePip( + label: tr.translate('pv_control.view_left_pip'), + crop: Alignment.centerLeft, + pos: _leftPipPos!, + size: Size(pipW, pipH), + bounds: Size(w, h), + onMove: (p) => setState(() => _leftPipPos = p), + ), + _sidePip( + label: tr.translate('pv_control.view_right_pip'), + crop: Alignment.centerRight, + pos: _rightPipPos!, + size: Size(pipW, pipH), + bounds: Size(w, h), + onMove: (p) => setState(() => _rightPipPos = p), + ), + ], + ), + ); + } + + /// 单个视角浮窗:画面按**宽度**放大 2 倍(高度不变)后按左 / 右边缘裁切。 + /// 窗体比例已由 [pvFrameRatioOf] 推算,因此放大后的画面恰好铺满窗体: + /// 等比不扭曲、无黑边、半幅画面完整可见。 + /// 描边靠 padding 内缩后再裁剪:Container 先画装饰再画子节点, + /// 不内缩子节点就会盖在边框上(“边框被视频遮盖一部分”就是这个原因)。 + /// USB / 以太网 RTSP 链路会话由页面级单例 cubit 提供,浮窗只是多一层渲染订阅, + /// 不会重复拉流 / 重复占用采集设备;WebRTC 链路每实例自建连接,浮窗会另开拉流。 + Widget _sidePip({ + required String label, + required Alignment crop, + required Offset pos, + required Size size, + required Size bounds, + required ValueChanged onMove, + }) { + final border = d(1.2); // 描边宽度(设计像素) + return Positioned( + left: pos.dx, + top: pos.dy, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (d0) => onMove( + Offset( + (pos.dx + d0.delta.dx).clamp(0.0, bounds.width - size.width), + (pos.dy + d0.delta.dy).clamp(0.0, bounds.height - size.height), + ), + ), + child: Container( + width: size.width, + height: size.height, + padding: EdgeInsets.all(border), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(d(10)), + border: Border.all(color: PvColors.cardBorder, width: border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: d(14), + offset: const Offset(0, 6), + ), + ], + ), + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(d(9)), + child: LayoutBuilder( + builder: (context, c) => OverflowBox( + // 只把可用宽翻倍,高度保持窗体高:与 cropRatio = 源比例 / 2 配套 + maxWidth: c.maxWidth * 2, + maxHeight: c.maxHeight, + alignment: crop, + child: _buildVideoLayer(), + ), + ), + ), + Positioned( + left: d(6), + bottom: d(6), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: d(6), + vertical: d(2), + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(d(4)), + ), + child: Text( + label, + style: s(PvType.caption, 11).copyWith( + color: PvColors.accentBright, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // ===== ④ 左侧信息栈 ===== + double _leftW(double w) => (w * 0.23).clamp(d(150), d(240)); + + Widget _buildLeftColumn(double w, double h) { + final tr = AppLocalizations.of(context); + final leftW = _leftW(w); + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + left: _leftCollapsed ? -(leftW + d(14)) : d(10), + top: d(56), + bottom: d(10), + width: leftW, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 地图卡本体已抽为 HUD 顶层浮层(点「地图」飞到屏幕中央), + // 这里按同样高度占位,否则下面的卡会顶上去被浮层盖住。 + SizedBox(height: _mapDockH(leftW)), + SizedBox(height: d(8)), + _progressCard(), + SizedBox(height: d(8)), + _statusCard(), + SizedBox(height: d(8)), + PvRearViewCard( + scale: _sc, + // 倒车影像:设置面板开启后叠加引导线;弯曲实时跟右摇杆转向。 + // 未来接入下位机后,ReverseCameraData 的 gear/steeringAngle/ + // rearDistanceCm 全部改成读回传数据即可,本组件不改。 + title: widget.simReverse + ? tr.translate('pv_control.reverse_camera') + : tr.translate('pv_control.rear_view'), + overlay: widget.simReverse + ? ValueListenableBuilder( + valueListenable: _steerDeg, + builder: (context, deg, _) => ReverseGuideOverlay( + data: ReverseCameraData( + gear: 2, + steeringAngle: deg, + speed: 0.4, + rearDistanceCm: 120, + ), + ), + ) + : null, + ), + ], + ), + ), + ); + } + + Widget _panel({required Widget child, EdgeInsets? padding}) { + return Container( + padding: padding ?? EdgeInsets.all(d(9)), + decoration: PvColors.panel(radius: d(12)), + child: child, + ); + } + + Widget _mapCard() { + final tr = AppLocalizations.of(context); + return _panel( + padding: EdgeInsets.all(d(6)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 地图区吃掉除底部作业信息行之外的全部高度:小卡 / 中央大卡同一套布局自动适配 + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(d(8)), + // 与路径规划同源同图层的只读实时地图(图层/定位两个按钮已内置在图里): + // 小卡禁手势避免和列表滚动、摇杆抢手势,飞到中央放大后才允许拖动跟随 + child: PvWorkMap( + deviceSn: widget.deviceSn, + interactive: _showBigMap, + scale: _sc, + ), + ), + ), + SizedBox(height: d(6)), + Row( + children: [ + Expanded( + child: Text( + tr.translate('pv_control.map_current_task'), + overflow: TextOverflow.ellipsis, + style: s( + PvType.label, + 12, + ).copyWith(color: PvColors.textPrimary), + ), + ), + GestureDetector( + onTap: () => debugPrint('[PV遥控] 切换任务'), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: d(8), + vertical: d(3), + ), + decoration: BoxDecoration( + color: PvColors.cardInner, + borderRadius: BorderRadius.circular(d(12)), + border: Border.all(color: PvColors.cardBorder), + ), + child: Text( + tr.translate('pv_control.map_switch'), + style: s(PvType.caption, 11), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _progressCard() { + final tr = AppLocalizations.of(context); + return _panel( + child: Row( + // 顶部对齐:三列的标签与数值同高,避免“一大一小”错位 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _progressItem( + tr.translate('pv_control.work_progress'), + '68%', + showBar: true, + ), + ), + _vDivider(), + Expanded( + child: _progressItem( + tr.translate('pv_control.work_area_size'), + '2.5', + unit: '/ 3.6 ha', + ), + ), + _vDivider(), + Expanded( + child: _progressItem( + tr.translate('pv_control.work_duration'), + '01:42:18', + ), + ), + ], + ), + ); + } + + Widget _progressItem( + String label, + String value, { + String? unit, + bool showBar = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: s(PvType.caption, 11)), + SizedBox(height: d(3)), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: s( + PvType.metricText, + 15, + ).copyWith(color: PvColors.accentBright), + ), + if (unit != null) ...[ + SizedBox(width: d(2)), + Text(unit, style: s(PvType.caption, 11)), + ], + ], + ), + if (showBar) ...[ + SizedBox(height: d(4)), + ClipRRect( + borderRadius: BorderRadius.circular(d(3)), + child: LinearProgressIndicator( + value: 0.68, + minHeight: d(4), + backgroundColor: PvColors.cardInner, + valueColor: const AlwaysStoppedAnimation(PvColors.online), + ), + ), + ], + ], + ); + } + + Widget _vDivider() => + Container(width: 1, height: d(30), color: PvColors.cardBorder); + + Widget _statusCard() { + final tr = AppLocalizations.of(context); + return _panel( + child: Column( + children: [ + _statusRow( + tr.translate('pv_control.vehicle_status'), + tr.translate('pv_control.status_normal'), + valueColor: PvColors.online, + ), + _statusRow( + tr.translate('pv_control.drive_speed'), + '1.2 m/s', + ), + _statusRow( + tr.translate('pv_control.gear'), + tr.translate('pv_control.gear_low'), + ), + _statusRow(tr.translate('pv_control.chassis_height'), '120 mm'), + _statusRow( + tr.translate('pv_control.mower_status'), + tr.translate('pv_control.mower_running'), + valueColor: PvColors.online, + ), + _statusRow( + tr.translate('pv_control.battery_label'), + '80% (5.6 h)', + ), + ], + ), + ); + } + + Widget _statusRow(String label, String value, {Color? valueColor}) { + return Padding( + padding: EdgeInsets.symmetric(vertical: d(4)), + child: Row( + children: [ + Icon(Icons.circle, size: d(6), color: PvColors.textTertiary), + SizedBox(width: d(6)), + Text(label, style: s(PvType.label, 12)), + const Spacer(), + Text( + value, + style: s(PvType.body, 13).copyWith( + color: valueColor ?? PvColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + // ===== ⑤ 右侧工具栏 + 急停 ===== + Widget _buildRightRail(double h) { + final tr = AppLocalizations.of(context); + final railW = d(66); + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + right: _rightCollapsed ? -(railW + d(14)) : d(10), + top: d(56), + bottom: d(10), + width: railW, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _railBtn( + Icons.home_work, + tr.translate('pv_control.rail_return_home'), + ), + SizedBox(height: d(8)), + _railBtn( + Icons.center_focus_strong, + tr.translate('pv_control.rail_find_vehicle'), + ), + SizedBox(height: d(8)), + _railBtn( + Icons.pause, + tr.translate('pv_control.rail_pause_work'), + ), + SizedBox(height: d(8)), + _railBtn(Icons.lightbulb, tr.translate('pv_control.rail_light')), + SizedBox(height: d(8)), + _railBtn(Icons.volume_up, tr.translate('pv_control.rail_horn')), + SizedBox(height: d(8)), + _railBtn( + Icons.videocam, + tr.translate('pv_control.rail_record_shot'), + ), + ], + ), + ), + ), + // 高速 / 低速:移出滚动区,固定在急停上方,保证始终可见 + SizedBox(height: d(8)), + _speedBtn(Icons.keyboard_arrow_up, tr.translate('pv_control.speed_high')), + SizedBox(height: d(6)), + _speedBtn( + Icons.keyboard_arrow_down, + tr.translate('pv_control.speed_low'), + ), + SizedBox(height: d(8)), + PvEmergencyStopButton( + onTap: widget.onEmergencyStop, + size: d(64), + iconSize: d(32), + gap: d(4), + labelSize: d(12), + ), + ], + ), + ); + } + + Widget _railBtn(IconData icon, String label, {bool small = false}) { + return GestureDetector( + onTap: () => debugPrint('[PV遥控] $label'), + behavior: HitTestBehavior.opaque, + child: Container( + width: d(66), + height: small ? d(46) : d(58), + decoration: PvColors.panel(radius: d(12)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: PvColors.accentBright, size: d(small ? 18 : 22)), + SizedBox(height: d(2)), + Text(label, style: s(PvType.caption, small ? 10 : 11)), + ], + ), + ), + ); + } + + /// 高速 / 低速小胶囊:图标 + 文字横排,固定在急停上方。 + Widget _speedBtn(IconData icon, String label) { + return GestureDetector( + onTap: () => debugPrint('[PV遥控] $label'), + behavior: HitTestBehavior.opaque, + child: Container( + width: d(66), + height: d(40), + decoration: PvColors.panel(radius: d(12)), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: PvColors.accentBright, size: d(20)), + SizedBox(width: d(2)), + Text(label, style: s(PvType.caption, 12)), + ], + ), + ), + ); + } + + // ===== 折叠把手(左 / 右两侧面板) ===== + Widget _buildLeftTab(double w, double h) { + final leftW = _leftW(w); + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + left: _leftCollapsed ? d(2) : d(10) + leftW + d(4), + top: h * 0.5 - d(23), + child: _collapseTab( + baseIcon: Icons.chevron_left, + collapsed: _leftCollapsed, + onTap: () => setState(() => _leftCollapsed = !_leftCollapsed), + ), + ); + } + + Widget _buildRightTab(double h) { + final railW = d(66); + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + right: _rightCollapsed ? d(2) : d(10) + railW + d(4), + top: h * 0.5 - d(23), + child: _collapseTab( + baseIcon: Icons.chevron_right, + collapsed: _rightCollapsed, + onTap: () => setState(() => _rightCollapsed = !_rightCollapsed), + ), + ); + } + + /// 小而美的折叠把手:半透明竖条 + 人字箭头,箭头随折叠状态平滑旋转 180°。 + Widget _collapseTab({ + required IconData baseIcon, + required bool collapsed, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedRotation( + turns: collapsed ? 0.5 : 0.0, + duration: _foldDur, + curve: _foldCurve, + child: Container( + width: d(16), + height: d(46), + alignment: Alignment.center, + decoration: BoxDecoration( + color: PvColors.card, + borderRadius: BorderRadius.circular(d(8)), + border: Border.all(color: PvColors.cardBorder), + ), + child: Icon(baseIcon, color: PvColors.accentBright, size: d(16)), + ), + ), + ); + } + + // ===== ⑥ 双摇杆 ===== + Widget _buildJoysticks(double w, double h) { + final radius = (math.min(w, h) * 0.16).clamp(d(50), d(88)); + final size = radius * 2; + // 折叠时摇杆向外侧滑动,展开时向内收,避免与两侧面板挤压 + final leftJL = _leftCollapsed ? d(24) : w * 0.22; + final rightJR = _rightCollapsed ? d(24) : w * 0.16; + return Stack( + children: [ + AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + left: leftJL, + bottom: d(30), + child: CCJoystick( + radius: radius, + axisHint: AxisHint.forwardBackward, + onValueChanged: (v) => widget.channel?.sendDriveY(v.y.toInt()), + onPanEnd: () async => widget.channel?.stopAllMovement(), + ), + ), + AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + right: rightJR, + bottom: d(30), + child: CCJoystick( + radius: radius, + axisHint: AxisHint.leftRight, + onValueChanged: (v) { + final int x = v.x.toInt().abs() < 1 ? 0 : v.x.toInt(); + widget.channel?.sendDriveX(x); + // 右摇杆(-100~100) → 转向角(-45°~45°),驱动引导线左右弯曲 + _steerDeg.value = (x * 0.45).clamp(-45.0, 45.0); + }, + onPanEnd: () async { + _steerDeg.value = 0; // 松手回正,引导线恢复直线 + await widget.channel?.stopAllMovement(); + }, + ), + ), + // 回中按钮(左摇杆右上方,抬高避开底部条) + AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + left: leftJL + size + d(6), + bottom: d(64), + child: GestureDetector( + onTap: () => widget.channel?.stopAllMovement(), + child: Container( + width: d(40), + height: d(40), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.45), + border: Border.all(color: PvColors.cardBorder), + ), + child: Icon( + Icons.refresh, + color: PvColors.textPrimary, + size: d(20), + ), + ), + ), + ), + ], + ); + } + + // ===== ⑦ 底部操作条 ===== + Widget _buildBottomBar(double w, double h) { + final tr = AppLocalizations.of(context); + final radius = (math.min(w, h) * 0.16).clamp(d(50), d(88)); + final size = radius * 2; + final leftJL = _leftCollapsed ? d(24) : w * 0.22; + final rightJR = _rightCollapsed ? d(24) : w * 0.16; + return AnimatedPositioned( + duration: _foldDur, + curve: _foldCurve, + bottom: d(12), + // 在两个摇杆内侧之间动态居中:折叠腾出空间后自动放宽、回正 + left: leftJL + size + d(12), + right: rightJR + size + d(12), + child: Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: d(14), vertical: d(6)), + decoration: PvColors.panel(radius: d(14)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _barSvgBtn( + 'assets/svgs/remote_scissors.svg', + tr.translate('pv_control.sheet_mower_title'), + sheetKey: 'mower', + ), + _barSep(), + _barSvgBtn( + 'assets/svgs/remote_layers.svg', + tr.translate('pv_control.sheet_chassis_title'), + sheetKey: 'chassis', + ), + _barSep(), + _barSvgBtn( + 'assets/svgs/remote_flame.svg', + tr.translate('pv_control.sheet_fire_title'), + sheetKey: 'fire', + iconTurns: 1, + ), + _barSep(), + _barModeChip(), + ], + ), + ), + ), + ); + } + + Widget _barSep() => Container( + width: 1, + height: d(28), + color: PvColors.cardBorder, + margin: EdgeInsets.symmetric(horizontal: d(6)), + ); + + /// 「作业模式」实时展示:显示 host control_mode 对应文案(拨杆当前模式), + /// 只做信息展示、不随点击切换;未收到数据时显示"未知"。 + Widget _barModeChip() { + final tr = AppLocalizations.of(context); + final tint = PvColors.textPrimary; + return Padding( + padding: EdgeInsets.symmetric(horizontal: d(12), vertical: d(3)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.autorenew, color: tint, size: d(22)), + SizedBox(height: d(2)), + Text( + tr.translate(_controlModeText(widget.telemetry.controlMode)), + maxLines: 1, + style: s(PvType.caption, 11).copyWith(color: tint), + ), + ], + ), + ); + } + + /// 底部条按钮:图标在上、单行文字在下(排版跟原来一致)。 + /// + /// 「点击之后」的点亮样式抄远程遥控 `TopStatusBar._buildSwitchIconButton`: + /// 同一个 `0xFF0078D4` 底色 + 同色外发光,200ms `AnimatedContainer` 渐变; + /// 未点亮时保持完全透明,不给整条按钮栏加框、破坏原来的观感。 + + /// [_barBtn] 的 svg 版:图标换成远程遥控同款 svg(剪刀 / 图层 / 火焰), + /// 统一染色跟文字同色,避免原图自带颜色在暗底上失衡。 + /// + /// [iconTurns]:火焰素材内部预旋过 90°(为远程遥控顶栏的横向滑块做的), + /// 底部条里竖着摆要传 1 转回正,其余止立图标不用传。 + Widget _barSvgBtn( + String svg, + String label, { + String? sheetKey, + int iconTurns = 0, + }) => _barButton( + label: label, + svg: svg, + sheetKey: sheetKey, + iconTurns: iconTurns, + ); + + Widget _barButton({ + required String label, + IconData? iconData, + String? svg, + String? sheetKey, + int iconTurns = 0, + }) { + final hasSheet = sheetKey != null; + // 带浮层的按钮点亮态跟着浮层走(浮层开着就亮),其余按钮是点一下切换的开关态。 + final active = hasSheet + ? _sheetKey == sheetKey + : _barActive.contains(label); + final tint = active ? Colors.white : PvColors.textPrimary; + final rawIcon = svg != null + ? SvgPicture.asset( + svg, + width: d(22), + height: d(22), + colorFilter: ColorFilter.mode(tint, BlendMode.srcIn), + ) + : Icon(iconData, color: tint, size: d(22)); + final icon = iconTurns == 0 + ? rawIcon + : RotatedBox(quarterTurns: iconTurns, child: rawIcon); + return GestureDetector( + onTap: () { + if (hasSheet) { + final tr = AppLocalizations.of(context); + CapsuleToast.show( + tr.translate('pv_control.scrub_hint').replaceAll('{label}', label), + showCheck: false, + ); + return; + } + setState(() { + if (active) { + _barActive.remove(label); + } else { + _barActive.add(label); + } + }); + debugPrint('[PV遥控] $label ${active ? '关' : '开'}'); + }, + // 长按就地拖动:不弹面板,按住后直接左/右滑切段,读数显示在屏幕中央气泡。 + onLongPressStart: hasSheet ? (_) => _onScrubStart(sheetKey) : null, + onLongPressMoveUpdate: hasSheet + ? (e) => _onScrubUpdate(sheetKey, e.localOffsetFromOrigin.dx) + : null, + onLongPressEnd: hasSheet ? (_) => _onScrubEnd(sheetKey) : null, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + padding: EdgeInsets.symmetric(horizontal: d(12), vertical: d(3)), + decoration: BoxDecoration( + color: active ? const Color(0xFF0078D4) : Colors.transparent, + borderRadius: BorderRadius.circular(d(9)), + boxShadow: active + ? [ + BoxShadow( + color: const Color(0xFF0078D4).withValues(alpha: 0.45), + blurRadius: d(10), + offset: Offset(0, d(2)), + ), + ] + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + SizedBox(height: d(2)), + Text( + label, + maxLines: 1, + style: s(PvType.caption, 11).copyWith(color: tint), + ), + ], + ), + ), + ); + } + + // ===== ⑧ 操作浮层(长按底部条按钮唤起)===== + + /// 三个长按操作各自的文案与三段图标;段位指令值统一 1 / 0 / 2,跟远程遥控的 + /// sendMowerCommand / sendChassisCommand / sendFireCommand 同一套编码。 + static const Map _sheets = { + 'mower': ( + title: 'pv_control.sheet_mower_title', + hint: 'pv_control.sheet_mower_hint', + up: 'assets/svgs/remote_up.svg', + mid: 'assets/svgs/remote_scissors.svg', + down: 'assets/svgs/remote_down.svg', + segUp: 'pv_control.seg_mower_up', + segMid: 'pv_control.seg_stop', + segDown: 'pv_control.seg_mower_down', + turns: 0, + ), + 'chassis': ( + title: 'pv_control.sheet_chassis_title', + hint: 'pv_control.sheet_chassis_hint', + up: 'assets/svgs/remote_up.svg', + mid: 'assets/svgs/remote_layers.svg', + down: 'assets/svgs/remote_down.svg', + segUp: 'pv_control.seg_chassis_up', + segMid: 'pv_control.seg_stop', + segDown: 'pv_control.seg_chassis_down', + turns: 0, + ), + 'fire': ( + title: 'pv_control.sheet_fire_title', + hint: 'pv_control.sheet_fire_hint', + up: 'assets/svgs/remote_flame_plus.svg', + mid: 'assets/svgs/remote_flame.svg', + down: 'assets/svgs/remote_flame_minus.svg', + segUp: 'pv_control.seg_fire_up', + segMid: 'pv_control.seg_stop', + segDown: 'pv_control.seg_fire_down', + turns: 1, + ), + }; + + /// 拖动时在屏幕中央显示的**非遮挡读数气泡**:横排三段(左 / 中 / 右), + /// 左=升、右=降,跟左右滑动手势同方向;当前段位高亮。手指仍在底部按钮上 + /// 拖动,气泡不拦截任何手势(IgnorePointer)。 + Widget _buildScrubBubble() { + final key = _sheetKey; + final spec = key == null ? null : _sheets[key]; + if (key == null || spec == null) return const SizedBox.shrink(); + final tr = AppLocalizations.of(context); + final seg = _scrubSeg; + // 升降箭头(remote_up/down 本是竖直的)转成指向左右;火焰(plus/minus)按原 turns 回正。 + final arrowTurns = spec.turns == 0 ? 3 : spec.turns; + Widget tile(String svg, String text, int value, {required int iconTurns}) { + final on = seg == value; + return Container( + padding: EdgeInsets.symmetric(horizontal: d(12), vertical: d(8)), + decoration: BoxDecoration( + color: on + ? const Color(0xFF0078D4) + : Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(d(10)), + boxShadow: on + ? [ + BoxShadow( + color: const Color(0xFF0078D4).withValues(alpha: 0.5), + blurRadius: d(12), + offset: Offset(0, d(2)), + ), + ] + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RotatedBox( + quarterTurns: iconTurns, + child: SvgPicture.asset( + svg, + width: d(22), + height: d(22), + colorFilter: ColorFilter.mode( + on ? Colors.white : PvColors.textSecondary, + BlendMode.srcIn, + ), + ), + ), + SizedBox(height: d(3)), + Text( + text, + maxLines: 1, + style: s( + PvType.caption, + 11, + ).copyWith(color: on ? Colors.white : PvColors.textSecondary), + ), + ], + ), + ); + } + + return Positioned.fill( + // 读数气泡不参与命中测试,不遮挡底下的视频 / 摇杆 / 按钮。 + child: IgnorePointer( + child: Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: d(14), vertical: d(10)), + decoration: PvColors.panel(radius: d(14)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr.translate(spec.title), style: s(PvType.cardTitle, 13)), + SizedBox(height: d(2)), + Text( + tr.translate(spec.hint), + style: s( + PvType.caption, + 9, + ).copyWith(color: PvColors.textSecondary), + ), + SizedBox(height: d(8)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + tile(spec.up, tr.translate(spec.segUp), 1, iconTurns: arrowTurns), + SizedBox(width: d(6)), + tile(spec.mid, tr.translate(spec.segMid), 0, iconTurns: spec.turns), + SizedBox(width: d(6)), + tile(spec.down, tr.translate(spec.segDown), 2, iconTurns: arrowTurns), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// 长按起手:给一下中等强度震动做确认(手指按在按钮上就触发了,无反馈很 + /// 容易被当成没响应),并亮气泡、从中位(停)开始。 + void _onScrubStart(String sheetKey) { + _sheetCloseTimer?.cancel(); + _sheetCloseTimer = null; + HapticFeedback.mediumImpact(); + setState(() { + _sheetKey = sheetKey; + _scrubSeg = 0; + }); + // 复刻远程遥控滑块:按住期间每 100ms 重发当前段位(松手前持续给指令, + // 底盘 / 割刀 / 点火才会持续动作),不是只在越界那一刻发一次。 + _scrubTimer?.cancel(); + _scrubTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + final key = _sheetKey; + if (key == null || !mounted) return; + _dispatch(key, _scrubSeg); + }); + } + + /// 拖动中:以起手点为基准算横向偏移 dx,超过阈值即左(升/1)/右(降/2),否则中(0)。 + /// 左滑 dx 为负。只在**越界换段**时轻振一次并下发对应指令,避免重复刷同值。 + /// + /// 改成左右滑:按钮在底部、往下几乎没空间够不到「降」;横向左右都有整屏 + /// 宽度可拖,故两侧用同一阈值即可。左=升(1)、右=降(2)。 + void _onScrubUpdate(String sheetKey, double dx) { + if (_sheetKey != sheetKey) return; + final threshold = d(30); + final seg = dx < -threshold ? 1 : (dx > threshold ? 2 : 0); + if (seg == _scrubSeg) return; + HapticFeedback.lightImpact(); + setState(() => _scrubSeg = seg); + _dispatch(sheetKey, seg); + } + + /// 松手:回中位并发停(0)——与旧滑块“松手回中”一致;随后短暂保留气泡 + /// 显示“停”,500ms 后自动隐藏并熄掉按钮高亮。 + void _onScrubEnd(String sheetKey) { + if (_sheetKey != sheetKey) return; + _scrubTimer?.cancel(); + _scrubTimer = null; + _dispatch(sheetKey, 0); + setState(() => _scrubSeg = 0); + _sheetCloseTimer?.cancel(); + _sheetCloseTimer = Timer(const Duration(milliseconds: 500), () { + if (mounted) setState(() => _sheetKey = null); + }); + } + + /// 段位 → 指令值:上=1、中=0、下=2,与远程遥控三处滑块完全一致 + /// (割刀 1 抬 / 2 降、底盘 1 升 / 2 降、点火 1 点火 / 2 熄火,0 都是停止)。 + /// + /// 已接入 [PvRobotCommandChannel]:割刀 / 底盘 / 点火分别经它下发,TCP 实现委托 + /// `RemoteControlCubit` 的 sendMower/Chassis/FireCommand;换蓝牙/下位机只替换实现。 + void _dispatch(String sheetKey, int value) { + final cmd = widget.channel; + switch (sheetKey) { + case 'mower': + cmd?.sendMower(value); + case 'chassis': + cmd?.sendChassis(value); + case 'fire': + cmd?.sendIgnition(value); + } + debugPrint('[PV遥控] ${_sheets[sheetKey]?.title ?? sheetKey} → cmd=$value'); + } +} + +/// 告警时屏幕四周泛红层:多层同心描边由外向内逐级变窄变亮 + 大 sigma 辉光, +/// 叠出「从屏幕边缘向里晕开」的红晕而不是一根线; +/// 与预警横幅同源(_pulse)呼吸,保证成套效果「同时出、同节奏」。 +class _PvRedGlowPainter extends CustomPainter { + _PvRedGlowPainter({required this.alpha, required this.unit}); + + /// 呼吸透明度 0~1(由 _pulse.value 驱动)。 + final double alpha; + + /// 缩放系数(_sc),手机横屏 / 平板泛红粗细一致。 + final double unit; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + // 只往画面内晕开:裁到屏幕矩形,外溢的笔宽/辉光不参与合成 + canvas.save(); + canvas.clipRect(rect); + + // 第一层:最宽最柔的内晕(边缘向里约 60 设计像素的红色渐变带) + canvas.drawRRect( + RRect.fromRectAndRadius( + rect.deflate(unit * 26), + Radius.circular(unit * 30), + ), + Paint() + ..color = PvColors.danger.withValues( + alpha: (0.16 * alpha).clamp(0.0, 1.0).toDouble(), + ) + ..style = PaintingStyle.stroke + ..strokeWidth = unit * 56 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, unit * 26), + ); + // 第二层:中等浓度过渡环,把渐变带抬亮 + canvas.drawRRect( + RRect.fromRectAndRadius( + rect.deflate(unit * 10), + Radius.circular(unit * 24), + ), + Paint() + ..color = PvColors.danger.withValues( + alpha: (0.32 * alpha).clamp(0.0, 1.0).toDouble(), + ) + ..style = PaintingStyle.stroke + ..strokeWidth = unit * 20 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, unit * 12), + ); + // 第三层:贴边高亮细线,给出边缘轮廓 + canvas.drawRRect( + RRect.fromRectAndRadius( + rect.deflate(unit * 2.5), + Radius.circular(unit * 18), + ), + Paint() + ..color = PvColors.danger.withValues( + alpha: (0.9 * alpha).clamp(0.0, 1.0).toDouble(), + ) + ..style = PaintingStyle.stroke + ..strokeWidth = unit * 3.5 + ..maskFilter = MaskFilter.blur(BlurStyle.solid, unit * 4), + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _PvRedGlowPainter oldDelegate) => + oldDelegate.alpha != alpha || oldDelegate.unit != unit; +} + +/// 假 AR 导向层:中央绿色行进箭头 + 两侧车道线。 +/// 脊柱以画面底部正中为固定顶点,随 [steering](-45~45)做等曲率圆弧弯曲, +/// 车道线沿法线守恒宽度地跟随,箭头沿弧线切线放置;steering=0 即回到原直线形态。 +class _PvArPainter extends CustomPainter { + _PvArPainter({this.progress = 0, this.steering = 0}); + + /// 0..1 循环进度,驱动绿色箭头的流动。 + final double progress; + + /// 转向角(度,右正左负,-45~45):驱动辅助线左右弯曲,当前由右摇杆喂入。 + final double steering; + + /// 近端(底部顶点)纵向比例 + static const double _bottomT = 0.98; + + /// 远端(收束)纵向比例 + static const double _topT = 0.52; + + /// 满舵时脊柱总转角(弧度):越大弯得越急;嫌弯不够调大、嫌夸张调小。 + static const double _maxHeading = 0.5; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final cx = w / 2; + final bottomY = h * _bottomT; + final topY = h * _topT; + + // 转向比例 -1 满左 / +1 满右 / 0 直行 → 有符号总转角(决定弯向与弯度) + // 近零死区:摇杆回中或极小漂移时强制视为直行,杜绝静态抖动 + final rawTurn = (steering / 45.0).clamp(-1.0, 1.0); + final turn = rawTurn.abs() < 0.02 ? 0.0 : rawTurn; + final totalHeading = turn * _maxHeading; + + // 航向积分采样脊柱中心线:底部正中出发、初始竖直向上 + const steps = 36; + final ds = (bottomY - topY) / steps; + final pts = []; + final heads = []; + double x = cx, y = bottomY, th = 0; + for (var i = 0; i <= steps; i++) { + pts.add(Offset(x, y)); + heads.add(th); + x += math.sin(th) * ds; + y -= math.cos(th) * ds; + th += totalHeading / steps; + } + + // 连续插值取脊柱点/切线角:避免用整型下标量化导致的“折线抖动” + Offset spineAt(double f) { + final pos = (f * steps).clamp(0.0, steps.toDouble()); + final i = pos.floor().clamp(0, steps - 1); + return Offset.lerp(pts[i], pts[i + 1], pos - i)!; + } + + double headAt(double f) { + final pos = (f * steps).clamp(0.0, steps.toDouble()); + final i = pos.floor().clamp(0, steps - 1); + return heads[i] + (heads[i + 1] - heads[i]) * (pos - i); + } + + // 沿脊柱法线偏移半宽得到一条车道线(root/far 为相对 w 的半宽比例,近宽远窄汇聚) + void lane( + double rootHalf, + double farHalf, + Color color, + double widthPx, + double sign, + ) { + final p = Paint() + ..color = color + ..strokeWidth = widthPx + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + final path = Path(); + const seg = 40; + for (var s = 0; s <= seg; s++) { + final f = s / seg; + final half = (rootHalf + (farHalf - rootHalf) * f) * w; + final n = Offset(math.cos(headAt(f)), math.sin(headAt(f))); + final pt = spineAt(f) + n * (half * sign); + if (s == 0) { + path.moveTo(pt.dx, pt.dy); + } else { + path.lineTo(pt.dx, pt.dy); + } + } + canvas.drawPath(path, p); + } + + // 外黄内绿两侧车道线(半宽比例与原直线版一致) + lane(0.14, 0.05, const Color(0xCCFFC24D), 4, -1); + lane(0.14, 0.05, const Color(0xCCFFC24D), 4, 1); + lane(0.08, 0.02, const Color(0xAA22E0A0), 3, -1); + lane(0.08, 0.02, const Color(0xAA22E0A0), 3, 1); + + // 中央绿色导向箭头:沿弯曲脊柱放置并随切线旋转,虚实渐隐 + const n = 4; + for (var i = 0; i < n; i++) { + final phase = (progress + i / n) % 1.0; // 0=近/底 → 1=远/顶 + final ay = h * (0.90 - phase * 0.34); + final f = ((bottomY - ay) / (bottomY - topY)).clamp(0.0, 1.0).toDouble(); + final half = w * (0.105 - phase * 0.05); + final drop = half * 0.72; + final env = math.sin(phase * math.pi); + final arrow = Paint() + ..color = const Color( + 0xFF22E0A0, + ).withValues(alpha: (0.12 + 0.8 * env).clamp(0.0, 0.95).toDouble()) + ..strokeWidth = 2.0 + 6.0 * env + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + final c = spineAt(f); + canvas.save(); + canvas.translate(c.dx, c.dy); + canvas.rotate(headAt(f)); + final path = Path() + ..moveTo(-half, drop) + ..lineTo(0, 0) + ..lineTo(half, drop); + canvas.drawPath(path, arrow); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant _PvArPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.steering != steering; +} diff --git a/lib/features/pv_control/presentation/widgets/pv_rear_view_card.dart b/lib/features/pv_control/presentation/widgets/pv_rear_view_card.dart new file mode 100644 index 00000000..bbae7409 --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_rear_view_card.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../theme/pv_colors.dart'; + +/// 可复用的「倒车影像 / 后视图」卡片组件。 +/// +/// 从遥控 HUD 左栏抽出,纯展示 + 插槽设计,方便多处复用: +/// - [streamUrl] 非空时用 [WebRTCLocalPlayer] 播放视频流,空时显示占位图标 +/// (当前阶段为模块还原,接嵌入式后视流后直接传 url 即可,无需改组件); +/// - [overlay] 可叠加任意覆盖层(例如将来把 remote_control 的 +/// ReverseGuideOverlay 倒车引导线传进来叠在画面上),组件不感知其实现; +/// - [scale] 沿用遥控 HUD 的 _sc 缩放体系,手机横屏 / 平板等比自适应; +/// - [onExpand] 右上角全屏按钮回调,null 时默认打印占位日志。 +class PvRearViewCard extends StatelessWidget { + const PvRearViewCard({ + super.key, + this.title, + this.streamUrl = '', + this.scale = 1, + this.videoHeight, + this.overlay, + this.onExpand, + }); + + /// 卡片标题;为空时根据当前语言回退为「后视图」。 + final String? title; + + /// 视频流地址;为空时显示无信号占位。 + final String streamUrl; + + /// 缩放系数(HUD 传入 _sc;独立使用时保持默认 1 即为设计像素)。 + final double scale; + + /// 画面区高度,默认 70 设计像素 × [scale]。 + final double? videoHeight; + + /// 叠在画面之上的覆盖层(如倒车引导线动画),不拦截手势。 + final Widget? overlay; + + /// 右上角全屏按钮回调。 + final VoidCallback? onExpand; + + double _d(num v) => v * scale; + + TextStyle _s(TextStyle base, double size) => base.copyWith(fontSize: _d(size)); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(_d(6)), + decoration: PvColors.panel(radius: _d(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title ?? + AppLocalizations.of( + context, + ).translate('pv_control.rear_view'), + style: _s(PvType.label, 12), + ), + SizedBox(height: _d(4)), + SizedBox( + height: videoHeight ?? _d(70), + child: ClipRRect( + borderRadius: BorderRadius.circular(_d(8)), + child: Container( + color: Colors.black54, + child: Stack( + fit: StackFit.expand, + children: [ + // 画面:有流播流,无流占位 + if (streamUrl.isNotEmpty) + WebRTCLocalPlayer( + streamUrl: streamUrl, + showLeftPip: false, + showRightPip: false, + mainViewAlignment: Alignment.center, + ) + else + const Center( + child: Icon(Icons.no_photography, + color: Colors.white24, size: 26), + ), + // 覆盖层插槽(倒车引导线等),不吃手势 + if (overlay != null) + Positioned.fill(child: IgnorePointer(child: overlay!)), + // 全屏按钮 + Positioned( + right: _d(4), + top: _d(4), + child: GestureDetector( + onTap: onExpand ?? () => debugPrint('[PV遥控] 后视图放大'), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.all(_d(2)), + child: Icon(Icons.fullscreen, + color: PvColors.textSecondary, size: _d(16)), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart b/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart new file mode 100644 index 00000000..90dd586a --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; + +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../models/pv_video_fit.dart'; +import '../models/pv_video_source.dart'; +import '../theme/pv_colors.dart'; + +/// 页面设置面板(底部弹出,遮罩半透明可透视下方视频,边调边看效果)。 +/// +/// 纯展示 + 回调:所有设置状态由 PvControlPage 持有。当前四项: +/// - 视频源:以太网 RTSP / HDMI(USB 采集卡) / WebRTC,切换即重建对应链路 cubit; +/// - 视频展示:完整显示(不裁画面、可能留黑边)/ 铺满屏幕(无黑边、裁掉边缘); +/// - 模拟障碍物信息:真实推送未接入前手动触发,开启 = 相当于收到机器告警推送, +/// 预警横幅 + AR 障碍框 + 屏幕四周泛红**同时**显示; +/// - 倒车影像:开启后左栏后视卡叠加倒车引导线效果(模拟倒车挡)。 +class PvSettingsSheet extends StatelessWidget { + const PvSettingsSheet({ + super.key, + required this.videoSource, + required this.videoFit, + required this.simObstacle, + required this.simReverse, + required this.onVideoSourceChanged, + required this.onVideoFitChanged, + required this.onSimObstacleChanged, + required this.onSimReverseChanged, + }); + + final PvVideoSource videoSource; + final PvVideoFit videoFit; + final bool simObstacle; + final bool simReverse; + final ValueChanged onVideoSourceChanged; + final ValueChanged onVideoFitChanged; + final ValueChanged onSimObstacleChanged; + final ValueChanged onSimReverseChanged; + + /// 视频源选项:顺序与文案按需求固定(以太网 RTSP 源、HDMI、WebRTC)。 + static const List<(PvVideoSource, String)> _sources = [ + (PvVideoSource.localRtsp, 'pv_control.set_source_rtsp'), + (PvVideoSource.usbCamera, 'pv_control.set_source_hdmi'), + (PvVideoSource.webrtc, 'pv_control.set_source_webrtc'), + ]; + + /// 视频展示选项:文案说清“取舍”以免用户选完还是困惑。 + static const List<(PvVideoFit, String)> _fits = [ + (PvVideoFit.contain, 'pv_control.set_fit_contain'), + (PvVideoFit.cover, 'pv_control.set_fit_cover'), + ]; + + @override + Widget build(BuildContext context) { + final tr = AppLocalizations.of(context); + // 横屏可用高度有限:最大高度限幅 + 可滚动防底部裁切; + // 视频源行用 Wrap,窄屏 / 大字体下药丸自动换行不溢出。 + final maxH = MediaQuery.sizeOf(context).height * 0.72; + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxH), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 12), + child: Container( + padding: const EdgeInsets.fromLTRB(14, 4, 10, 8), + decoration: BoxDecoration( + color: PvColors.card, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PvColors.cardBorder), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + 关闭 + Row( + children: [ + const Icon( + Icons.settings_outlined, + color: PvColors.accentBright, + size: 18, + ), + const SizedBox(width: 8), + Text( + tr.translate('pv_control.nav_settings'), + style: PvType.cardTitle, + ), + const Spacer(), + IconButton( + icon: const Icon( + Icons.close, + color: PvColors.textSecondary, + size: 20, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + // 视频源(Wrap:空间不够自动换第二行) + Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + tr.translate('pv_control.set_video_source'), + style: PvType.label, + ), + for (final (src, label) in _sources) + _pill( + label: tr.translate(label), + selected: src == videoSource, + onTap: () => onVideoSourceChanged(src), + ), + ], + ), + // 视频展示(适配方式)+ 一句取舍说明 + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + tr.translate('pv_control.set_video_fit'), + style: PvType.label, + ), + for (final (fitMode, label) in _fits) + _pill( + label: tr.translate(label), + selected: fitMode == videoFit, + onTap: () => onVideoFitChanged(fitMode), + ), + Text( + videoFit == PvVideoFit.contain + ? tr.translate('pv_control.set_fit_contain_hint') + : tr.translate('pv_control.set_fit_cover_hint'), + style: PvType.caption.copyWith( + color: PvColors.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 4), + _switchTile( + icon: Icons.warning_amber_rounded, + iconColor: + simObstacle ? PvColors.danger : PvColors.textTertiary, + title: tr.translate('pv_control.set_sim_obstacle'), + subtitle: tr.translate('pv_control.set_sim_obstacle_sub'), + value: simObstacle, + onChanged: onSimObstacleChanged, + ), + _switchTile( + icon: Icons.directions_car, + iconColor: + simReverse ? PvColors.accentBright : PvColors.textTertiary, + title: tr.translate('pv_control.set_sim_reverse'), + subtitle: tr.translate('pv_control.set_sim_reverse_sub'), + value: simReverse, + onChanged: onSimReverseChanged, + ), + ], + ), + ), + ), + ), + ); + } + + /// 药丸选项:选中态青色描边 + 填充,与 HUD 预设档位风格一致(视频源 / 视频展示共用)。 + /// 左右间距由外层 Wrap 的 spacing 提供,此处只留纵向呼吸。 + Widget _pill({ + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected + ? PvColors.accent.withValues(alpha: 0.20) + : PvColors.cardInner, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? PvColors.accent : PvColors.cardBorder, + ), + ), + child: Text( + label, + style: PvType.body.copyWith( + color: selected + ? PvColors.accentBright + : PvColors.textSecondary, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ); + } + + /// 开关行:图标 + 标题/说明 + Switch(整行可点按切换,热区更大)。 + Widget _switchTile({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: PvType.cardTitle), + Text( + subtitle, + style: PvType.caption.copyWith(height: 1.3), + ), + ], + ), + ), + // Switch 自身禁用手势,统一走整行 onTap,避免两套状态 + IgnorePointer( + child: Switch( + value: value, + onChanged: (_) {}, + activeThumbColor: PvColors.accentBright, + activeTrackColor: PvColors.accent.withValues(alpha: 0.35), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_task_dialog.dart b/lib/features/pv_control/presentation/widgets/pv_task_dialog.dart new file mode 100644 index 00000000..ccbf8cdc --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_task_dialog.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_task_entity.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_cubit.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_state.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../theme/pv_colors.dart'; + +/// 「当前任务」弹窗:展示路径规划模块正在执行的任务。 +/// +/// 入口为遥控 HUD 顶栏右上第一项「任务」。数据源与路径规划作业面板同源—— +/// 全局单例 [DeviceTaskCubit] 的 currentTask(用 BlocBuilder 实时订阅, +/// 任务状态变化自动刷新);接口没给的字段统一显示「无」。 +class PvTaskDialog { + const PvTaskDialog._(); + + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (_) => BlocProvider.value( + // 共享全局实例:弹窗只订阅状态,不负责 cubit 生命周期 + value: sl(), + child: const _PvTaskDialogContent(), + ), + ); + } +} + +class _PvTaskDialogContent extends StatelessWidget { + const _PvTaskDialogContent(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final task = state.currentTask; + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 60), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 8, 12, 14), + decoration: PvColors.panel(radius: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + 关闭 + Row( + children: [ + const Icon( + Icons.assignment_outlined, + color: PvColors.accentBright, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.task_current'), + style: PvType.cardTitle, + ), + const Spacer(), + IconButton( + icon: const Icon( + Icons.close, + color: PvColors.textSecondary, + size: 20, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + if (task == null) + const _EmptyTask() + else + _TaskFields(task: task), + ], + ), + ), + ); + }, + ); + } +} + +/// 无执行任务:路径规划侧 currentTask 为空(未下发/已完成/被清除)。 +class _EmptyTask extends StatelessWidget { + const _EmptyTask(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.info_outline, + color: PvColors.textTertiary, + size: 18, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.task_none_running'), + style: PvType.body, + ), + ], + ), + ); + } +} + +/// 任务数据项:与路径规划作业面板展示的字段对齐 +/// (任务编号 / 设备 / 状态,另补路线、地块、创建时间),空字段显示「无」。 +class _TaskFields extends StatelessWidget { + const _TaskFields({required this.task}); + + final DeviceTaskEntity task; + + @override + Widget build(BuildContext context) { + final tr = AppLocalizations.of(context); + final (statusColor, statusText) = _statusOf(context, task); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _row(context, tr.translate('pv_control.task_field_no'), '#${task.id}'), + _row( + context, + tr.translate('pv_control.task_field_device'), + task.deviceId, + ), + _row( + context, + tr.translate('pv_control.task_field_status'), + statusText, + valueColor: statusColor, + ), + _row( + context, + tr.translate('pv_control.task_field_route'), + task.routeId?.toString(), + ), + _row( + context, + tr.translate('pv_control.task_field_site'), + task.siteId?.toString(), + ), + _row( + context, + tr.translate('pv_control.task_field_create_time'), + task.createTime, + ), + ], + ); + } + + /// 状态映射与路径规划页一致:NEW 蓝 / EXECUTING 绿 / PAUSE 橙, + /// 其余用接口给的翻译文案;颜色换成本模块语义色保持风格统一。 + (Color, String) _statusOf(BuildContext context, DeviceTaskEntity t) { + final tr = AppLocalizations.of(context); + final translate = t.taskStatusTranslate.isNotEmpty + ? t.taskStatusTranslate + : t.taskStatus; + switch (t.taskStatus) { + case 'NEW': + return ( + PvColors.info, + translate.isEmpty + ? tr.translate('pv_control.task_none_value') + : tr.translate('pv_control.task_new'), + ); + case 'EXECUTING': + return (PvColors.online, tr.translate('pv_control.task_executing')); + case 'PAUSE': + return (PvColors.warning, tr.translate('pv_control.task_paused')); + default: + return ( + PvColors.textSecondary, + translate.isEmpty + ? tr.translate('pv_control.task_none_value') + : translate, + ); + } + } + + Widget _row( + BuildContext context, + String label, + String? value, { + Color? valueColor, + }) { + final text = (value == null || value.isEmpty) + ? AppLocalizations.of(context).translate('pv_control.task_none_value') + : value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 76, + child: Text(label, style: PvType.label), + ), + Expanded( + child: Text( + text, + style: PvType.body.copyWith( + color: valueColor ?? PvColors.textPrimary, + fontWeight: valueColor != null + ? FontWeight.w700 + : FontWeight.w400, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_usb_camera_video.dart b/lib/features/pv_control/presentation/widgets/pv_usb_camera_video.dart new file mode 100644 index 00000000..4b07b4bb --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_usb_camera_video.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/cubit/usb_camera_state.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/widgets/usb_camera_preview.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/widgets/usb_camera_status_layer.dart'; + +/// USB 摄像头(UVC)链路的视频视图。 +/// +/// 复用 usb_camera 模块现成的预览组件与状态层(扫描中 / 多设备选择 / +/// 授权失败可重试),行为与「USB 摄像头」独立页一致。 +/// +/// 前置条件:上层已挂好 `BlocProvider`(由 pv_control_page +/// 负责)—— cubit 必须是页面级**单实例**,普通态与全屏 HUD 共用: +/// UVC 是独占硬件会话,两处各建一个 cubit 会互相抢占设备。 +class PvUsbCameraVideo extends StatelessWidget { + const PvUsbCameraVideo({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + if (state.showPreview && state.selected != null) { + return UsbCameraPreview( + device: state.selected!, + phase: state.phase, + onReconnect: cubit.reconnect, + aspectRatio: state.previewAspectRatio, + ); + } + return UsbCameraStatusLayer(state: state, cubit: cubit); + }, + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_video_area.dart b/lib/features/pv_control/presentation/widgets/pv_video_area.dart new file mode 100644 index 00000000..e1141f32 --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_video_area.dart @@ -0,0 +1,392 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart'; + +import '../models/pv_telemetry.dart'; +import '../models/pv_video_fit.dart'; +import '../models/pv_video_source.dart'; +import '../theme/pv_colors.dart'; +import 'pv_local_rtsp_video.dart'; +import 'pv_usb_camera_video.dart'; +import 'pv_video_fit_box.dart'; + +/// 实时视频区:底层播放 WebRTC 流,上层叠加设计稿中的信息浮层。 +/// +/// 浮层(左上分辨率、顶部障碍预警、右上网络状态、右侧工具按钮、左下罗盘) +/// 目前为静态样式还原,障碍物测距等 AR 数据需真实算法接入后驱动。 +class PvVideoArea extends StatelessWidget { + const PvVideoArea({ + super.key, + required this.streamUrl, + required this.telemetry, + this.onFullscreen, + this.onLocate, + this.onScreenshot, + this.showSideTools = true, + this.showCompass = true, + this.videoSource = PvVideoSource.webrtc, + this.videoFit = PvVideoFit.contain, + }); + + final String streamUrl; + final PvTelemetry telemetry; + final VoidCallback? onFullscreen; + final VoidCallback? onLocate; + final VoidCallback? onScreenshot; + + /// 手动控制全屏态可隐藏右侧工具按钮,避免与摇杆冲突 + final bool showSideTools; + + /// 隐藏左下罗盘(手动控制全屏态下摇杆占据下角,避免与罗盘重叠) + final bool showCompass; + + /// 视频链路(多源可扩展):webrtc=云端流 / localRtsp=以太网 RTSP / usbCamera=USB 采集卡。 + /// cubit 由页面层按链路单实例提供(pv_control_page._wrapVideoProvider)。 + final PvVideoSource videoSource; + + /// 视频展示方式(设置面板可改):contain 完整显示(可能留黑边)/ cover 等比铺满(裁边)。 + final PvVideoFit videoFit; + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: PvColors.panel(radius: 16, fill: Colors.black, glow: true), + child: Stack( + fit: StackFit.expand, + children: [ + // ① 视频层:按链路枚举分发,再按「视频展示」档位做 contain / cover 适配 + PvVideoFitBox( + fit: videoFit, + source: videoSource, + child: _buildLayer(), + ), + + // ② 顶部左侧:实时视频 + 分辨率/帧率 + Positioned(left: 14, top: 12, child: _buildTopLeftBadge()), + + // ③ 顶部中间:障碍物预警 + if (telemetry.obstacleAlert) + Positioned(left: 0, right: 0, top: 10, child: _buildObstacleBanner()), + + // ④ 顶部右侧:网络状态 + Positioned(right: 14, top: 12, child: _buildNetworkBadge()), + + // ⑤ 右侧工具按钮列 + if (showSideTools) + Positioned(right: 12, top: 54, child: _buildSideTools()), + + // ⑥ 左下罗盘 + if (showCompass) Positioned(left: 14, bottom: 14, child: _buildCompass()), + + // ⑦ 圆角描边覆盖层:视频画面会把 Container 边框盖住, + // 这里在最上层单独画一圈青色圆角边框,保证四角圆角 + 边框始终可见。 + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PvColors.cardBorder, width: 1.5), + ), + ), + ), + ), + ], + ), + ); + } + + /// 各链路的原始视频层(组件内部自己等比居中)。 + Widget _buildLayer() { + if (videoSource == PvVideoSource.usbCamera) return const PvUsbCameraVideo(); + if (videoSource == PvVideoSource.localRtsp) return const PvLocalRtspVideo(); + if (streamUrl.isNotEmpty) { + return WebRTCLocalPlayer( + streamUrl: streamUrl, + showLeftPip: false, + showRightPip: false, + mainViewAlignment: Alignment.center, + ); + } + return _VideoPlaceholder(); + } + + Widget _buildTopLeftBadge() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.circle, size: 8, color: PvColors.danger), + const SizedBox(width: 5), + const Text( + '实时视频', + style: TextStyle( + color: PvColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${telemetry.resolution} | ${telemetry.fps}fps', + style: const TextStyle( + color: PvColors.textSecondary, + fontSize: 9, + shadows: [Shadow(blurRadius: 4, color: Colors.black)], + ), + ), + ], + ); + } + + Widget _buildObstacleBanner() => const _ObstacleBannerPill(); + + Widget _buildNetworkBadge() { + final ok = telemetry.networkNormal; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: (ok ? PvColors.online : PvColors.warning).withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wifi, size: 14, color: ok ? PvColors.online : PvColors.warning), + const SizedBox(width: 4), + Text( + ok ? '网络良好' : '网络异常', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: ok ? PvColors.online : PvColors.warning, + ), + ), + ], + ), + ); + } + + Widget _buildSideTools() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ToolButton(icon: Icons.fullscreen, onTap: onFullscreen), + const SizedBox(height: 10), + _ToolButton(icon: Icons.person_search, onTap: onLocate), + const SizedBox(height: 10), + _ToolButton(icon: Icons.photo_camera, onTap: onScreenshot), + ], + ); + } + + Widget _buildCompass() { + return SizedBox( + width: 80, + height: 80, + child: CustomPaint(painter: _CompassPainter()), + ); + } +} + +class _VideoPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black87, + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam_off, size: 48, color: Colors.white38), + SizedBox(height: 8), + Text('视频未加载', style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ), + ); + } +} + +/// 障碍物预警横幅:脉动动画(透明度 + 轻微缩放 + 红色辉光呼吸)以引起注意。 +class _ObstacleBannerPill extends StatefulWidget { + const _ObstacleBannerPill(); + + @override + State<_ObstacleBannerPill> createState() => _ObstacleBannerPillState(); +} + +class _ObstacleBannerPillState extends State<_ObstacleBannerPill> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 750), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; // 0..1 来回 + return Center( + child: Opacity( + opacity: 0.72 + 0.28 * t, + child: Transform.scale( + scale: 1.0 + 0.07 * t, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 7), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFE01E32), Color(0xFFFF5A4D)], + ), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: PvColors.danger.withValues(alpha: 0.3 + 0.4 * t), + blurRadius: 12 + 16 * t, + spreadRadius: 1 + 2 * t, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const _Chevron(left: true), + const SizedBox(width: 7), + const Icon(Icons.warning_rounded, color: Colors.white, size: 16), + const SizedBox(width: 5), + const Text( + '障碍物预警', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 7), + const _Chevron(left: false), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _Chevron extends StatelessWidget { + const _Chevron({required this.left}); + final bool left; + + @override + Widget build(BuildContext context) { + return Icon( + left ? Icons.chevron_left : Icons.chevron_right, + color: Colors.white70, + size: 18, + ); + } +} + +class _ToolButton extends StatelessWidget { + const _ToolButton({required this.icon, this.onTap}); + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: PvColors.cardBorder), + ), + child: Icon(icon, color: PvColors.textPrimary, size: 18), + ), + ); + } +} + +/// 简易罗盘(N/E/S/W + 中心车辆图标 + 指向扇形) +class _CompassPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final r = size.width / 2; + + // 外圈 + final ring = Paint() + ..color = PvColors.accent.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle(center, r - 1, ring); + + // 指向扇形(北方向红色) + final sector = Paint() + ..color = PvColors.danger.withValues(alpha: 0.55) + ..style = PaintingStyle.fill; + canvas.drawArc( + Rect.fromCircle(center: center, radius: r - 2), + -math.pi / 2 - 0.5, + 1.0, + true, + sector, + ); + + const labels = { + 'N': -math.pi / 2, + 'E': 0.0, + 'S': math.pi / 2, + 'W': math.pi, + }; + for (final e in labels.entries) { + final p = Offset( + center.dx + math.cos(e.value) * (r - 12), + center.dy + math.sin(e.value) * (r - 12), + ); + final tp = TextPainter( + text: TextSpan( + text: e.key, + style: const TextStyle(color: PvColors.textPrimary, fontSize: 11, fontWeight: FontWeight.bold), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, p - Offset(tp.width / 2, tp.height / 2)); + } + + // 中心车辆图标(用简单圆点代替) + final dot = Paint()..color = PvColors.accentBright; + canvas.drawCircle(center, 4, dot); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/pv_control/presentation/widgets/pv_video_fit_box.dart b/lib/features/pv_control/presentation/widgets/pv_video_fit_box.dart new file mode 100644 index 00000000..e80ee5e1 --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_video_fit_box.dart @@ -0,0 +1,86 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart'; + +import '../models/pv_video_fit.dart'; +import '../models/pv_video_source.dart'; + +/// 当前视频链路实际画面的宽高比(拿不到尺寸时回退 16:9)。 +/// +/// 这里用 [BuildContext.watch]:流刚拉起时比例往往是空 / 0,真实分辨率到达后 +/// 调用方会重建,裁切尺寸随之重算,不会一直停在 16:9 的兜底值上 +/// (BlocProvider 在每次状态发射时都会通知 watch 的依赖方)。 +/// (USB / RTSP 的 cubit 必须已由页面层挂好。) +double pvFrameRatioOf(BuildContext context, PvVideoSource source) { + switch (source) { + case PvVideoSource.usbCamera: + final r = context + .watch() + .state + .previewAspectRatio ?? + 0.0; + return r > 0 ? r : 16 / 9; + case PvVideoSource.localRtsp: + // 画面尺寸在 controller 里、不在 state 里:watch 一次,出图后重新取值 + context.watch(); + final c = context.read().videoController; + final r = + (c != null && c.value.isInitialized) ? c.value.aspectRatio : 0.0; + return r > 0 ? r : 16 / 9; + case PvVideoSource.webrtc: + // WebRTC 侧未对外暴露 renderer 尺寸,沿用播放器内部的 16:9 主视口。 + return 16 / 9; + } +} + +/// 按 [PvVideoFit] 把视频层铺进展示区域。 +/// +/// 各链路的视频组件内部都是「等比 + 居中」(contain):窗口比例和画面比例不一致时 +/// 会留黑边。这里不改那些组件(USB / 本地视频是独立模块,尽量零侵入), +/// 而是给它套一个**比例正好等于画面比例**的盒子: +/// - [PvVideoFit.contain]:原样返回,窗口多大就在中间显示多大(留黑边); +/// - [PvVideoFit.cover]:用 OverflowBox 把子层放到 `max(窗宽, 窗高×比例) × max(窗高, 窗宽÷比例)` +/// 的盒子里 —— 该盒子比例与画面完全一致,子层的 contain 适配于是变成「刚好铺满」, +/// 再由外层 ClipRect 裁掉超出窗口的部分:等比、无黑边、只裁边缘,画面不扭曲。 +class PvVideoFitBox extends StatelessWidget { + const PvVideoFitBox({ + super.key, + required this.fit, + required this.source, + required this.child, + }); + + final PvVideoFit fit; + + /// 视频链路:用于查实际画面比例。 + final PvVideoSource source; + final Widget child; + + @override + Widget build(BuildContext context) { + if (fit == PvVideoFit.contain) return child; + final ratio = pvFrameRatioOf(context, source); + return LayoutBuilder( + builder: (context, c) { + final w = c.maxWidth; + final h = c.maxHeight; + // 约束不收敛时无法算裁切尺寸,直接原样显示 + if (!w.isFinite || !h.isFinite) return child; + final boxW = math.max(w, h * ratio); + final boxH = math.max(h, w / ratio); + return ClipRect( + child: OverflowBox( + maxWidth: boxW, + maxHeight: boxH, + alignment: Alignment.center, + child: child, + ), + ); + }, + ); + } +} diff --git a/lib/features/pv_control/presentation/widgets/pv_work_map.dart b/lib/features/pv_control/presentation/widgets/pv_work_map.dart new file mode 100644 index 00000000..a7ff10ce --- /dev/null +++ b/lib/features/pv_control/presentation/widgets/pv_work_map.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/route_status_entity.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../theme/pv_colors.dart'; + +/// HUD 作业地图:与「路径规划」同源、同图层、同样式的**只读**实时地图。 +/// +/// 图层顺序与配色完全照抄路径规划(testmap_pages._buildMap),只保留展示所需的部分: +/// ① ESRI 卫星底图 ② 场站无人机高清叠加层 ③ 规划路线(金黄) ④ 已走轨迹(绿) ⑤ 机器指针。 +/// 打点、障碍物、保存、开始作业等交互一概不做;路径规划的文件零改动。 +/// +/// 数据全部来自 MQTT(TCP 链路已停用): +/// - 机器位置/航向:全局单例 [DeviceStatusBloc] 的 `DeviceStatusUpdated` +/// (mower/{sn}/property/location/post,bloc 内 500ms 节流),与路径规划同一条流; +/// - 规划路线:`MowerRealtimeRepository.routeStatusStream`(device/{sn}/route/post, +/// 广播流,这里只是多一个订阅者,不会影响路径规划页的订阅)。 +/// +/// 与路径规划的唯一行为差异(按需求新增):机器**始终显示在正中间**。用户手拖/缩放 +/// 会暂停跟随,右下角「定位」按钮恢复。 +class PvWorkMap extends StatefulWidget { + const PvWorkMap({ + super.key, + this.deviceSn = '', + this.interactive = true, + this.scale = 1, + }); + + /// 设备 SN:用于过滤路线推送(deviceId 不匹配的消息直接丢弃)。 + /// 订阅本身由页面层 `startMqttRealtimeListening` 保证,这里不重复切换全局设备。 + final String deviceSn; + + /// 是否允许拖动/缩放:左栏小卡传 false(避免与列表滚动、摇杆抢手势), + /// 放大到屏幕中央的大卡传 true。 + final bool interactive; + + /// HUD 的缩放系数(与 pv_manual_control_view 的 _sc 一致),只影响两个浮动按钮大小。 + final double scale; + + @override + State createState() => _PvWorkMapState(); +} + +class _PvWorkMapState extends State { + // ===== 与路径规划一致的常量 ===== + static const _esriUrl = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; + static const _centerZoom = 18.0; // 路径规划回中/跟随都用 18 级 + static const _maxTrace = 600; // 绿线最多保留的点数(约 5 分钟推送) + static const _minTraceStep = 0.3; // 米:小于此间距的点丢弃(抖动过滤) + + final MapController _map = MapController(); + final Distance _dist = const Distance(); + + StreamSubscription? _routeSub; + + /// 场站高清叠加层:默认用内置常量,GetCapabilities 回来后换成动态范围 + List _stationLayers = kAllStationTileLayers; + bool _showStation = true; + + bool _ready = false; // 地图未 ready 前不能用 controller,否则断言 + bool _follow = true; // 机器居中跟随中(用户手势会关掉) + bool _moveScheduled = false; + + List _route = const []; + final List _trace = []; + int _routeTaskId = -1; + LatLng? _pos; + + @override + void initState() { + super.initState(); + // 图层范围仓库:与路径规划同一套用法(动态刷新 + 变更通知,失败自动回退内置兜底) + StationLayerRepo.instance.addListener(_onStationLayersUpdated); + StationLayerRepo.instance.refresh(); + _stationLayers = StationLayerRepo.instance.buildLayers(); + _routeSub = sl().routeStatusStream.listen( + _onRouteMessage, + ); + } + + @override + void didUpdateWidget(covariant PvWorkMap oldWidget) { + super.didUpdateWidget(oldWidget); + // 换设备:上一台的路线与轨迹都没有意义,清空重来 + if (oldWidget.deviceSn != widget.deviceSn) { + _route = const []; + _routeTaskId = -1; + _trace.clear(); + _pos = null; + _follow = true; + } + } + + @override + void dispose() { + StationLayerRepo.instance.removeListener(_onStationLayersUpdated); + _routeSub?.cancel(); + _map.dispose(); + super.dispose(); + } + + void _onStationLayersUpdated() { + if (!mounted) return; + setState(() => _stationLayers = StationLayerRepo.instance.buildLayers()); + } + + /// 规划路线(金黄线):后端在任务执行时推送整条覆盖清扫路线 + void _onRouteMessage(RouteStatusEntity msg) { + if (widget.deviceSn.isNotEmpty && + msg.deviceId.isNotEmpty && + msg.deviceId != widget.deviceSn) { + return; // 别的设备的路线不画 + } + if (msg.data.isEmpty || !mounted) return; + setState(() { + // 新任务 = 上一段的行走轨迹作废(路径规划选中任务时同样会 reset 轨迹) + if (msg.taskId != _routeTaskId) { + _routeTaskId = msg.taskId; + _trace.clear(); + } + _route = [for (final p in msg.data) LatLng(p.lat, p.lng)]; + }); + } + + /// 定位推送来了:攒轨迹 + 按需把机器移到正中间 + void _onLocation(DeviceStatusUpdated state) { + final p = _validLatLng(state.status.latitude, state.status.longitude); + if (p == null) return; // 无效坐标不画点、不追(与路径规划一致) + if (p == _pos) return; + _pos = p; + _appendTrace(p); + if (_follow) _scheduleMove(p); + } + + void _appendTrace(LatLng p) { + final last = _trace.isEmpty ? null : _trace.last; + if (last != null && _dist(last, p) < _minTraceStep) return; + _trace.add(p); + if (_trace.length > _maxTrace) { + _trace.removeRange(0, _trace.length - _maxTrace); + } + } + + /// 移动地图必须在帧结束后做:在 build / listener 里直接 move 会撞上 + /// “setState during build” 断言。一帧内多次推送只排一次。 + void _scheduleMove(LatLng p) { + if (_moveScheduled) return; + _moveScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _moveScheduled = false; + if (!mounted || !_ready) return; + _map.move(p, _centerZoom); + }); + } + + /// 用户自己的手势 → 暂停跟随,让他能看全局;点「定位」再恢复 + void _onMapEvent(MapEvent event) { + if (!_follow) return; + switch (event.source) { + case MapEventSource.onDrag: + case MapEventSource.dragEnd: + case MapEventSource.onMultiFinger: + case MapEventSource.multiFingerEnd: + case MapEventSource.flingAnimationController: + case MapEventSource.scrollWheel: + case MapEventSource.doubleTapZoomAnimationController: + setState(() => _follow = false); + default: + break; + } + } + + void _recenter() { + final p = _pos ?? _fallbackCenter(); + _follow = true; + if (_ready) _map.move(p, _centerZoom); + setState(() {}); + } + + /// 进场中心:优先当前选中场站高清图层中心,无有效场站兜底到苏州地块(同路径规划) + LatLng _fallbackCenter() => + stationLayerCenter(sl().state.selectedSite?.siteMapName) ?? + const LatLng(31.083296, 120.840355); + + LatLng? _validLatLng(double lat, double lng) { + if (lat == 0 || lng == 0) return null; // 排除赤道 0,0 + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return LatLng(lat, lng); + } + + double ds(num v) => v * widget.scale; + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, cur) => cur is DeviceStatusUpdated, + listener: (context, state) => _onLocation(state as DeviceStatusUpdated), + child: BlocBuilder( + buildWhen: (prev, cur) => cur is DeviceStatusUpdated, + builder: (context, state) { + final status = state is DeviceStatusUpdated ? state.status : null; + final marker = status == null + ? null + : _validLatLng(status.latitude, status.longitude); + return Stack( + fit: StackFit.expand, + children: [ + FlutterMap( + mapController: _map, + options: MapOptions( + initialCenter: _pos ?? _fallbackCenter(), + initialZoom: _centerZoom, + maxZoom: 22, + minZoom: 3, + // 小卡完全禁手势;大卡禁旋转(与路径规划一致:all & ~rotate) + interactionOptions: InteractionOptions( + flags: widget.interactive + ? InteractiveFlag.all & ~InteractiveFlag.rotate + : InteractiveFlag.none, + ), + onMapReady: () => _ready = true, + onMapEvent: _onMapEvent, + onTap: (_, _) {}, // 空实现:地图本身不响应点击 + ), + children: [ + /// ESRI World Imagery(WGS84,全球高清卫星图,无需坐标转换) + TileLayer( + urlTemplate: _esriUrl, + userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, + ), + + /// 场站无人机高清影像:滑到哪个场站区域(zoom>=16)就叠哪个 + if (_showStation) ..._stationLayers, + + /// 规划路线(金黄 + 白描边),参数与路径规划完全相同 + if (_route.isNotEmpty) + PolylineLayer( + polylines: [ + Polyline( + points: _route, + color: const Color.fromARGB(255, 255, 204, 0), + strokeWidth: 1.0, + borderColor: Colors.white, + borderStrokeWidth: 1, + ), + ], + ), + + /// 已走轨迹(绿色实线,本地按定位推送累积) + if (_trace.length >= 2) + PolylineLayer( + key: const ValueKey('pv_trace'), + polylines: [ + Polyline( + points: List.of(_trace), + color: const Color(0xFF00B42A), + strokeWidth: 4, + ), + ], + ), + + /// 机器指针(同一个 HeadingMarkerPainter:绿圆 + 白描边 + 航向箭头) + if (marker != null) + MarkerLayer( + markers: [ + Marker( + point: marker, + width: 40, + height: 40, + child: CustomPaint( + size: const Size(40, 40), + painter: HeadingMarkerPainter( + headingAngle: status?.yaw ?? 0, + ), + ), + ), + ], + ), + ], + ), + // 还没收到定位推送时的提示(此时图面停在场站中心,机器不出现) + if (marker == null) + Positioned( + left: ds(6), + top: ds(6), + child: _badge( + AppLocalizations.of( + context, + ).translate('pv_control.map_waiting_push'), + ), + ), + // 高清图层开关 + Positioned( + right: ds(4), + top: ds(4), + child: _floatButton( + Icons.layers, + active: _showStation, + onTap: () => setState(() => _showStation = !_showStation), + ), + ), + // 恢复跟随并回中 + Positioned( + right: ds(4), + bottom: ds(4), + child: _floatButton( + Icons.my_location, + active: _follow, + onTap: _recenter, + ), + ), + ], + ); + }, + ), + ); + } + + Widget _badge(String text) { + return Container( + padding: EdgeInsets.symmetric(horizontal: ds(6), vertical: ds(2)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(ds(4)), + ), + child: Text( + text, + style: TextStyle(fontSize: ds(10), color: PvColors.textSecondary), + ), + ); + } + + Widget _floatButton( + IconData icon, { + required bool active, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: ds(28), + height: ds(28), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(ds(7)), + border: Border.all(color: PvColors.cardBorder), + ), + child: Icon( + icon, + color: active + ? PvColors.accentBright + : PvColors.textTertiary, // 关掉的图层用灰图标,一眼能分辨 + size: ds(16), + ), + ), + ); + } +} diff --git a/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart b/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart index 9d0b230c..7d7b807b 100644 --- a/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart +++ b/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart @@ -2,17 +2,20 @@ import 'dart:typed_data'; import '../../../../core/protocol/machine_protocol_codec.dart'; +/// 0x00 远程遥控指令数据模型 +/// +/// 协议变更(2026-10):左右轮速改为线速度/角速度(double) class LawnMoverProtocolModel { - final int left; - final int right; + final double linear; + final double angular; final int lift; final int mower; final int ignition; final int emergency; LawnMoverProtocolModel({ - required this.left, - required this.right, + required this.linear, + required this.angular, this.lift = 0, this.mower = 0, this.ignition = 0, @@ -20,8 +23,8 @@ class LawnMoverProtocolModel { }); Uint8List toBytes() => MachineProtocolCodec.encodeRemoteControlPayload( - left: left, - right: right, + linear: linear, + angular: angular, lift: lift, mower: mower, ignition: ignition, diff --git a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart index 47e77d32..91f66022 100644 --- a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart +++ b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart @@ -35,26 +35,43 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository { @override void sendControlMachineCmd(MachineControlStatusEntity status) { - // 1. 调用算法:将摇杆坐标 (x, y) 转换为左右轮电机转速 + // 1. 调用算法:将摇杆坐标 (x, y) 转换为线速度/角速度(物理量) final speeds = _diffSteer.calculate(status.originX, status.originY); - _logger.logWithLevel( - '⚙️ [电机速度] left: ${speeds['left']}, right: ${speeds['right']}', - ); + final linear = speeds['linear']!; + final angular = speeds['angular']!; - // 2. 调用 Codec:生成协议要求的 8 字节 Payload + // 2. 调用 Codec:生成协议要求的 20 字节 Payload(线速度/角速度 double 直传) final payload = MachineProtocolCodec.encodeRemoteControlPayload( - left: speeds['left']!, - right: speeds['right']!, + linear: linear, + angular: angular, lift: status.chassisLift, mower: status.mowerSpeed, ignition: status.ignitionStatus, emergency: status.isEmergency ? 1 : 0, ); + // 联调日志:三段式打印 ①滑块/摇杆原始数据 ②乘完系数的发送值 ③包装后的整帧字节(联调结束可移除) + _logger.logWithLevel( + '🔗 [联调-0x00] 遥控发送明细\n' + '① 滑块/摇杆原始数据: x=${status.originX} y=${status.originY} ' + 'lift=${status.chassisLift} mower=${status.mowerSpeed} ' + 'ignition=${status.ignitionStatus} emergency=${status.isEmergency ? 1 : 0}\n' + '② 乘后发送值: linear=${linear.toStringAsFixed(3)} m/s, angular=${angular.toStringAsFixed(3)} rad/s\n' + '③ 包装后整帧(27字节): ab aa 00 | ${_hex(payload.sublist(0, 8))} | ' + '${_hex(payload.sublist(8, 16))} | ${_hex(payload.sublist(16, 20))} | 00 00 | aa ab\n' + ' 包头+cmd | linear@帧[3..10] | angular@帧[11..18] | ' + 'lift,mower,ignition,emergency@帧[19..22] | CRC | 包尾', + shouldLog: true, + ); + // 3. 调用 TcpClient 发送指令 _tcpClient.sendRaw(MachineProtocolConstants.cmdRemoteControl, payload); } + /// 字节转十六进制字符串,供联调对包使用 + String _hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + @override Stream get responseStream => _tcpClient.packetStream; diff --git a/lib/features/remote_control/data/reverse_camera_source.dart b/lib/features/remote_control/data/reverse_camera_source.dart new file mode 100644 index 00000000..26d642eb --- /dev/null +++ b/lib/features/remote_control/data/reverse_camera_source.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:math' as math; + +import '../domain/entities/reverse_camera_data.dart'; + +/// 倒车影像数据源抽象:UI/Cubit 只依赖这个流,不关心数据真假。 +abstract class ReverseCameraSource { + Stream get dataStream; + void start(); + Future stop(); +} + +/// Mock 源:嵌入式未就绪前,制造会“动起来”的假数据用于联调动画。 +/// +/// 将来接入真实数据时,只需在 Cubit 里把这里换成基于 +/// MQTT vehicleStream / TCP 0x02 回包解析的实现类,其余 UI / state 全不动。 +class MockReverseCameraSource implements ReverseCameraSource { + final StreamController _controller = + StreamController.broadcast(); + Timer? _timer; + final math.Random _rnd = math.Random(); + int _tick = 0; + + @override + Stream get dataStream => _controller.stream; + + @override + void start() { + stop(); + // ~15Hz 推送,动画足够顺滑;将来真数据 5~10Hz 也够用。 + _timer = Timer.periodic(const Duration(milliseconds: 66), (Timer t) { + _tick++; + // 转向角做正弦摆动(模拟倒车打方向),速度小幅抖动,距离缓慢变化便于测颜色刻度。 + final double sway = math.sin(_tick / 6.0) * 28.0; // -28° ~ +28° + final double speed = 0.3 + _rnd.nextDouble() * 0.5; // 0.3 ~ 0.8 m/s + final int dist = 40 + (_tick % 160); // 40 ~ 199cm,循环变化 + _controller.add( + ReverseCameraData( + gear: 2, // 假数据默认处于“倒车挡”,进页面即可看到动画 + steeringAngle: sway, + speed: speed, + rearDistanceCm: dist, + ), + ); + }); + } + + @override + Future stop() async { + _timer?.cancel(); + _timer = null; + if (!_controller.isClosed) { + _controller.add(const ReverseCameraData.idle()); + } + } + + /// 释放流控制器(Cubit close 时调用,避免资源泄漏)。 + Future dispose() async { + await stop(); + await _controller.close(); + } +} diff --git a/lib/features/remote_control/domain/entities/reverse_camera_data.dart b/lib/features/remote_control/domain/entities/reverse_camera_data.dart new file mode 100644 index 00000000..7ffc1656 --- /dev/null +++ b/lib/features/remote_control/domain/entities/reverse_camera_data.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; + +/// 倒车影像数据契约:将来嵌入式实时回传的字段就映射到这里。 +/// +/// 嵌入式协议对齐清单(按这些语义推送即可): +/// - gear:挡位。0=停车/空挡, 1=前进, 2=倒车。 +/// - steeringAngle:当前转向角,单位度,范围 -45(左)~+45(右),0=正后。 +/// - speed:当前实际速度,单位 m/s(>=0),预留给引导线流速等扩展动画。 +/// - rearDistanceCm:后部障碍物距离(厘米),null 表示无测距传感器。 +class ReverseCameraData extends Equatable { + final int gear; + final double steeringAngle; + final double speed; + final int? rearDistanceCm; + + const ReverseCameraData({ + this.gear = 0, + this.steeringAngle = 0, + this.speed = 0, + this.rearDistanceCm, + }); + + /// 初始/待机态:不显示倒车引导线。 + const ReverseCameraData.idle() + : gear = 0, + steeringAngle = 0, + speed = 0, + rearDistanceCm = null; + + /// 是否处于倒车挡(引导线显示的唯一开关)。 + bool get isReversing => gear == 2; + + ReverseCameraData copyWith({ + int? gear, + double? steeringAngle, + double? speed, + int? rearDistanceCm, + }) { + return ReverseCameraData( + gear: gear ?? this.gear, + steeringAngle: steeringAngle ?? this.steeringAngle, + speed: speed ?? this.speed, + rearDistanceCm: rearDistanceCm ?? this.rearDistanceCm, + ); + } + + @override + List get props => [gear, steeringAngle, speed, rearDistanceCm]; +} diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index 9975a957..b50224dd 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -22,7 +22,9 @@ import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remot import '../../../../core/logging/i_logger_service.dart'; import '../../../../core/network/net_message_dispatcher.dart'; import '../../../devices/domain/entities/running_status_entity.dart'; +import '../../data/reverse_camera_source.dart'; import '../../domain/entities/machine_control_status_entity.dart'; +import '../../domain/entities/reverse_camera_data.dart'; import '../../domain/repositories/remote_control_repository.dart'; import '../../domain/usecase/remote_control_usecase.dart'; @@ -51,6 +53,11 @@ class RemoteControlCubit extends Cubit { // 🔥 模拟数据推送定时器 // Timer? _simulationTimer; + // 🔥 倒车影像数据源(现为 Mock;嵌入式就绪后,把 startReverseCameraMock 里的 + // MockReverseCameraSource 换成解析真实回包的 Mqtt/Tcp 源即可,UI 不用动)。 + ReverseCameraSource? _reverseCameraSource; + StreamSubscription? _reverseCameraSub; + // 🔥 获取带时间戳的日志前缀 String _getTimePrefix() { final now = DateTime.now(); @@ -538,7 +545,7 @@ class RemoteControlCubit extends Cubit { }); } - // 2. 开启 100ms 控制循环 (在进入遥控页面或点击“开始”时调用) + // 2. 开启 200ms 控制循环 (在进入遥控页面或点击“开始”时调用) /* void startControlLoop() { _timer?.cancel(); _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { @@ -562,13 +569,13 @@ class RemoteControlCubit extends Cubit { _timer?.cancel(); final logPrefix = '⏰ [RemoteControl] [控制循环]'; //debugPrint('$logPrefix ========================================='); - //debugPrint('$logPrefix 启动控制循环,间隔: 100ms'); + //debugPrint('$logPrefix 启动控制循环,间隔: 200ms'); ///debugPrint('$logPrefix ========================================='); // 🔥 启动模拟设备状态推送(用于测试) // startSimulation(); - _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { + _timer = Timer.periodic(const Duration(milliseconds: 200), (timer) { if (isClosed) { // debugPrint('$logPrefix ❌ Cubit已关闭,取消定时器'); timer.cancel(); @@ -657,6 +664,9 @@ class RemoteControlCubit extends Cubit { final updatedEntity = state.controlEntity.copyWith(y: y); emit(state.copyWith(controlEntity: updatedEntity)); // debugPrint('📝 [updateOriginY] state已更新- originY=$y'); + + // 🔥 倒车影像:用当前摇杆(左杆前后)实时驱动引导线 + _syncReverseCameraFromJoystick(); } void updateOriginX(int x) { @@ -669,6 +679,37 @@ class RemoteControlCubit extends Cubit { final updatedEntity = state.controlEntity.copyWith(x: x); emit(state.copyWith(controlEntity: updatedEntity)); // debugPrint('📝 [updateOriginX] state已更新- originX=$x'); + + // 🔥 倒车影像:用当前摇杆(右杆转向)实时驱动引导线摆动 + _syncReverseCameraFromJoystick(); + } + + /// 🔥 开发阶段:暂用本地摇杆数据合成倒车影像(不依赖嵌入式回传)。 + /// + /// 激活规则(摇杆输出范围 -100~100,上推为正=前进、下拉为负=倒车,右杆为左右转向): + /// - 倒车(y<0) 或 正在转向(x≠0) → 显示引导线;纯前进直行或松手归零 → 自动收起。 + /// - originX(-100~100) → steeringAngle(-45°~45°),驱动引导线左右摆动。 + /// - 摇杆无测距传感器 → rearDistanceCm=null(颜色固定为绿)。 + /// 嵌入式就绪后,删除本方法及 updateOriginX/Y 里的调用,改回 startReverseCameraMock 接入真实数据源。 + void _syncReverseCameraFromJoystick() { + if (isClosed) return; + final int y = _currentOriginY; + final int x = _currentOriginX; + // 🔥 智能激活:倒车 或 正在转向 都显示引导线(解决“只动右杆不显示”) + final bool guideActive = y < 0 || x != 0; + final int gear = guideActive ? 2 : (y > 0 ? 1 : 0); + final double steering = (x.clamp(-100, 100) / 100.0) * 45.0; + final double speed = (y.abs().clamp(0, 100) / 100.0); + emit( + state.copyWith( + reverseCamera: ReverseCameraData( + gear: gear, + steeringAngle: steering, + speed: speed, + rearDistanceCm: null, + ), + ), + ); } /// 🔥 安全方法:同时清零双轴,确保只发送一次完全停止指令 @@ -690,6 +731,9 @@ class RemoteControlCubit extends Cubit { // 一次性更新双轴(即使无权限也要更新本地state) emit(state.copyWith(controlEntity: updatedEntity)); + // 🔥 摇杆归零:同步倒车影像为非倒车态(引导线自动隐藏) + _syncReverseCameraFromJoystick(); + // 🔥 安全底线:停止指令必须无视权限强制发送! // debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令- originX=0, originY=0'); await _sendStopCommandRepeatedly(updatedEntity); // 等待所有指令发送完成 @@ -781,14 +825,68 @@ class RemoteControlCubit extends Cubit { _timer?.cancel(); _deviceStatusSub?.cancel(); // 🔥 取消订阅 DeviceStatusBloc _tcpStatusSub?.cancel(); // 🔥 取消订阅 TCP 0x02 门槛监听 + _reverseCameraSub?.cancel(); // 🔥 取消订阅倒车影像数据流 + _reverseCameraSource?.stop(); return super.close(); } void updateChassisLift(int i) {} - void updateEmergency(bool bool) { - // debugPrint('🚨 [急停] ${bool ? "触发急停!" : "解除急停"}'); - updateFunction(emergency: bool); + /// 🔥 启动倒车影像数据流(开发期用 Mock 造假数据,机器未开也能联调动画)。 + /// 由遥控页 initState 调用;嵌入式就绪后只需替换这里传入的实现类。 + void startReverseCameraMock() { + if (_reverseCameraSub != null) return; // 已启动,避免重复订阅 + final source = MockReverseCameraSource(); + _reverseCameraSource = source; + _reverseCameraSub = source.dataStream.listen((ReverseCameraData data) { + if (isClosed) return; + emit(state.copyWith(reverseCamera: data)); + }); + source.start(); + debugPrint('🚗 [倒车影像] Mock 数据源已启动(嵌入式真数据接入前的联调用)'); + } + + /// 🔥 停止倒车影像数据流(退出遥控页调用,避免定时器空转)。 + void stopReverseCameraMock() { + _reverseCameraSub?.cancel(); + _reverseCameraSub = null; + _reverseCameraSource?.stop(); + _reverseCameraSource = null; + emit(state.copyWith(reverseCamera: const ReverseCameraData.idle())); + debugPrint('🚗 [倒车影像] 数据源已停止'); + } + + void updateEmergency(bool on) { + // debugPrint('🚨 [急停] ${on ? "触发急停!" : "解除急停"}'); + // ① 先置状态:急停时让 200ms 控制循环下一 tick 立刻停发运动指令; + // 解除时循环恢复正常发送。 + updateFunction(emergency: on); + // ② 再绕过控制循环的 isEmergency 门控,直发带急停位的指令帧(双重保险): + // - 按下:连发 emergency=1 + 全零,让设备明确收到「急停」,而不只靠「收不到指令」。 + // - 取消:连发 emergency=0 + 全零,让设备明确「解除急停、恢复正常」。 + _sendEmergencyFrames(emergency: on); + } + + /// 双重保险:绕过控制循环门控,直接连续补发若干帧带急停位的指令。 + /// + /// [emergency] true=显式急停(emergency 位=1),false=显式恢复正常(emergency 位=0)。 + /// 运动量一律全零,避免误带旧摇杆值;5ms 高频补发防丢包(安全路径,无视权限)。 + Future _sendEmergencyFrames({required bool emergency}) async { + final frame = MachineControlStatusEntity( + originX: 0, + originY: 0, + chassisLift: 0, + mowerSpeed: 0, + ignitionStatus: 0, + isEmergency: emergency, + ); + for (int i = 0; i < 10; i++) { + if (isClosed) return; + _repository.sendControlMachineCmd(frame); + if (i < 9) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } } void respondPermission(bool agreed, String deviceId) { diff --git a/lib/features/remote_control/presentation/bloc/remote_control_state.dart b/lib/features/remote_control/presentation/bloc/remote_control_state.dart index 6ec5196f..61afffe7 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_state.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_state.dart @@ -3,6 +3,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity. import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart'; import '../../domain/entities/machine_control_status_entity.dart'; +import '../../domain/entities/reverse_camera_data.dart'; enum RemoteControlStatus { initial, controlling, error } @@ -32,6 +33,9 @@ class RemoteControlState extends Equatable { final DeviceEntity? targetDevice; final bool hasReceivedStatusPush; // 是否收到过设备状态推送 + // 🔥 倒车影像数据(来自 ReverseCameraSource:现为 Mock,嵌入式就绪后接真实回传) + final ReverseCameraData reverseCamera; + // 🔥 状态更新类型标记 - 用于区分是设备状态更新还是弹窗状态更新 final String? updateType; @@ -59,6 +63,7 @@ class RemoteControlState extends Equatable { this.obstacleRecognitionFlag = true, this.targetDevice, this.updateType, + this.reverseCamera = const ReverseCameraData.idle(), }); // 便利 UI 更新部分属性 @@ -86,6 +91,7 @@ class RemoteControlState extends Equatable { bool? obstacleRecognitionFlag, DeviceEntity? targetDevice, String? updateType, + ReverseCameraData? reverseCamera, }) { return RemoteControlState( status: status ?? this.status, @@ -113,6 +119,7 @@ class RemoteControlState extends Equatable { obstacleRecognitionFlag ?? this.obstacleRecognitionFlag, targetDevice: targetDevice ?? this.targetDevice, updateType: updateType, // 不使用 ??,因为我们想要 null 的时候就是 null + reverseCamera: reverseCamera ?? this.reverseCamera, ); } @@ -141,5 +148,6 @@ class RemoteControlState extends Equatable { obstacleRecognitionFlag, targetDevice, updateType, + reverseCamera, ]; } diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index 457a9601..a115f3eb 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -19,6 +19,7 @@ import '../widgets/center_control_area.dart'; import '../widgets/emergency_overlay.dart'; import '../widgets/left_joystick_area.dart'; import '../widgets/right_joystick_area.dart'; +import '../widgets/driving_guide_overlay.dart'; import '../widgets/top_status_bar.dart'; import '../widgets/webrtc/webrtc_local_player.dart'; @@ -95,7 +96,7 @@ class _RemoteControlPageState extends State { super.initState(); // 🔥 进入安全模式:防止异地登录推送导致控制中断 GetIt.I().enterSafeMode(); - + // 1. 锁定横屏 SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, @@ -107,6 +108,10 @@ class _RemoteControlPageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { final remoteCubit = context.read(); + // 🔥 倒车影像:现阶段改用摇杆(滑块)数据驱动,不再启动 Mock 假数据源。 + // 恢复 Mock:取消下行注释即可(需与 cubit 里 _syncReverseCameraFromJoystick 的调用二选一)。 + // remoteCubit.startReverseCameraMock(); + // debugPrint('🔍 [RemoteControl] initState - targetDevice: ${remoteCubit.state.targetDevice}'); // 🔥 检查 targetDevice 是否存在 @@ -125,7 +130,7 @@ class _RemoteControlPageState extends State { void dispose() { // 🔥 离开安全模式:启动 90 秒倒计时 GetIt.I().exitSafeMode(); - + // 释放远程控制权限(type=2:退出远程遥控页面释放) final deviceId = _cubit.state.targetDevice?.deviceName; if (deviceId != null && deviceId.isNotEmpty) { @@ -141,6 +146,7 @@ class _RemoteControlPageState extends State { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); _cubit.stopControlLoop(); + _cubit.stopReverseCameraMock(); // 🔥 停止倒车影像 Mock,避免定时器空转 // 🔥 重置权限状态,避免单例 hasPermission 残留导致下次进入不再查询接口 _cubit.resetPermissionState(); super.dispose(); @@ -153,7 +159,9 @@ class _RemoteControlPageState extends State { final targetDevice = remoteCubit.state.targetDevice; // 🔥 调试日志:检查设备状态 - debugPrint('🔍 [RemoteControlPage] build - targetDevice: ${targetDevice?.deviceName}'); + debugPrint( + '🔍 [RemoteControlPage] build - targetDevice: ${targetDevice?.deviceName}', + ); if (targetDevice == null) { debugPrint('❌ [RemoteControlPage] targetDevice 为空,显示离线页面'); @@ -167,7 +175,9 @@ class _RemoteControlPageState extends State { final deviceName = targetDevice.deviceName; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && deviceName.isNotEmpty) { - debugPrint('🔑 [RemoteControlPage] 进入页面,仅查询权限状态 - deviceName: $deviceName'); + debugPrint( + '🔑 [RemoteControlPage] 进入页面,仅查询权限状态 - deviceName: $deviceName', + ); remoteCubit.requestControlPermissionS( deviceName, 'app', @@ -186,6 +196,7 @@ class _RemoteControlPageState extends State { Positioned.fill( child: BlocBuilder( // 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里 + // 🔥 倒车引导线放在单独嵌套 BlocBuilder 里实时刷新,不带着整块视频重建 buildWhen: (p, c) => p.showLeftPip != c.showLeftPip || p.showRightPip != c.showRightPip, @@ -219,17 +230,36 @@ class _RemoteControlPageState extends State { .controlEntity .originY; // debugPrint("${originY},originY"); - return WebRTCLocalPlayer( - streamUrl: _videoStreamUrl, - showLeftPip: state.showLeftPip, - showRightPip: state.showRightPip, - isFrontMain: originY >= 0, - onDoubleTap: () { - debugPrint('👆 [RemoteControlPage] 双击屏幕,切换前后视角'); - // 🔥 通过 TCP 发送切换视角指令 - // 这里需要调用 RemoteControlCubit 的方法来切换视角 - context.read().toggleCameraView(); - }, + return Stack( + children: [ + WebRTCLocalPlayer( + streamUrl: _videoStreamUrl, + showLeftPip: state.showLeftPip, + showRightPip: state.showRightPip, + isFrontMain: originY >= 0, + onDoubleTap: () { + debugPrint('👆 [RemoteControlPage] 双击屏幕,切换前后视角'); + // 🔥 通过 TCP 发送切换视角指令 + // 这里需要调用 RemoteControlCubit 的方法来切换视角 + context.read().toggleCameraView(); + }, + ), + // 🔥 倒车影像引导线动画层:由摇杆(滑块)数据实时驱动。 + // 嵌套 BlocBuilder 监听完整 reverseCamera,转向/挡位一变就重绘,确保跟手。 + // IgnorePointer 不阻挡双击切视角等手势 + BlocBuilder( + buildWhen: (p, c) => p.reverseCamera != c.reverseCamera, + builder: (context, rs) => rs.reverseCamera.isReversing + ? Positioned.fill( + child: IgnorePointer( + child: DrivingGuideOverlay( + steering: rs.reverseCamera.steeringAngle, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], ); }, ), @@ -540,5 +570,4 @@ class _RemoteControlPageState extends State { } /// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗 - } diff --git a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart index 29c78b10..c5ad5629 100644 --- a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart @@ -28,19 +28,23 @@ class _LeftJoystickAreaState extends State { /// 检查是否可以控制,不能则弹出提示 bool _checkCanControl(BuildContext context) { - final cubit = context.read(); - if (!cubit.isStatusPushActive()) { - if (!_hasShownNoPushDialog) { - _hasShownNoPushDialog = true; - _showNoStatusPushDialog(context); - } - return false; - } - _hasShownNoPushDialog = false; + // 🔥 临时关闭「未收到推送不能控制」门槛,便于机器未开时用 Mock 假数据联调倒车影像。 + // 联调完成后取消下面注释即可恢复。切勿删除本段逻辑。 + // final cubit = context.read(); + // if (!cubit.isStatusPushActive()) { + // if (!_hasShownNoPushDialog) { + // _hasShownNoPushDialog = true; + // _showNoStatusPushDialog(context); + // } + // return false; + // } + // _hasShownNoPushDialog = false; return true; } /// 显示暂未收到推送的提示弹窗(小圆角) + /// 🔥 门槛已临时注释,本方法暂未被调用:保留勿删,恢复门槛后即生效。 + // ignore: unused_element void _showNoStatusPushDialog(BuildContext context) { showDialog( context: context, diff --git a/lib/features/remote_control/presentation/widgets/reverse_guide_overlay.dart b/lib/features/remote_control/presentation/widgets/reverse_guide_overlay.dart new file mode 100644 index 00000000..3dd5689f --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/reverse_guide_overlay.dart @@ -0,0 +1,181 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../domain/entities/reverse_camera_data.dart'; + +/// 倒车影像引导线动画层:叠在视频画面之上,仅倒车挡时显示。 +/// +/// 动画由两部分驱动: +/// - 嵌入式回传数据([ReverseCameraData]):转向角 → 引导线绕底部顶点弯成圆弧, +/// 距离 → 颜色刻度; +/// - 本地 AnimationController:横向刻度线的流动虚线,营造“行进中”的动感。 +class ReverseGuideOverlay extends StatefulWidget { + final ReverseCameraData data; + + const ReverseGuideOverlay({super.key, required this.data}); + + @override + State createState() => _ReverseGuideOverlayState(); +} + +class _ReverseGuideOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ticker; + + @override + void initState() { + super.initState(); + _ticker = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.data.isReversing) return const SizedBox.shrink(); + return RepaintBoundary( + child: AnimatedBuilder( + animation: _ticker, + builder: (BuildContext context, Widget? child) => CustomPaint( + size: Size.infinite, + painter: _GuidePainter(data: widget.data, dashPhase: _ticker.value), + ), + ), + ); + } +} + +class _GuidePainter extends CustomPainter { + final ReverseCameraData data; + final double dashPhase; + + _GuidePainter({required this.data, required this.dashPhase}); + + /// 近端(画面底部)纵向位置比例 = 引导线的固定顶点(锚在车尾) + static const double _nearT = 0.98; + + /// 远端(收束线)纵向位置比例 + static const double _farT = 0.45; + + /// 满舵时的总转角(弧度):越大弯得越急,约 34°;嫌弯不够调大、嫌夸张调小。 + static const double _maxHeading = 0.6; + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width; + final double h = size.height; + + final double bottomY = h * _nearT; + final double len = bottomY - h * _farT; + + // 转向比例:-1 满左、+1 满右、0 直行;映射为有符号总转角(决定弯向与弯度) + final double turn = (data.steeringAngle / 45.0).clamp(-1.0, 1.0); + final double totalHeading = turn * _maxHeading; + + const int steps = 40; + final double ds = len / steps; + + // 航向积分采样中心线:起点在底部正中、初始竖直向上,逐段累加航向 → 等曲率圆弧 + final List pts = []; + final List heads = []; + double x = w / 2; + double y = bottomY; + double th = 0; + for (int i = 0; i <= steps; i++) { + pts.add(Offset(x, y)); + heads.add(th); + x += math.sin(th) * ds; + y -= math.cos(th) * ds; + th += totalHeading / steps; + } + + final double rootHalf = w * 0.16; + final double farHalf = w * 0.30; + final Color color = _colorByDistance(data.rearDistanceCm); + + // 中心线沿法线各偏移半宽 = 左右导轨(近窄远宽,转弯时通道宽度守恒) + Offset rail(int i, double sign) { + final double f = i / steps; + final double half = rootHalf + (farHalf - rootHalf) * f; + final Offset normal = Offset(math.cos(heads[i]), math.sin(heads[i])); + return pts[i] + normal * (half * sign); + } + + final Paint sidePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = math.max(3.0, w * 0.006) + ..strokeCap = StrokeCap.round + ..color = color + ..maskFilter = const MaskFilter.blur(BlurStyle.solid, 2); + + for (final double sign in const [-1.0, 1.0]) { + final Offset p0 = rail(0, sign); + final Path path = Path()..moveTo(p0.dx, p0.dy); + for (int i = 1; i <= steps; i++) { + final Offset r = rail(i, sign); + path.lineTo(r.dx, r.dy); + } + canvas.drawPath(path, sidePaint); + } + + final Paint dashPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = math.max(2.0, w * 0.004) + ..color = color.withValues(alpha: 0.9); + + // 横向刻度线:沿法线连接左右导轨;每条错开相位,虚线像“往外流动” + const List stops = [0.0, 0.34, 0.63, 0.86]; + for (int k = 0; k < stops.length; k++) { + final int i = (stops[k] * steps).round().clamp(0, steps); + _drawDashedLine( + canvas, + rail(i, -1.0), + rail(i, 1.0), + dashPaint, + (dashPhase + k * 0.25) % 1.0, + ); + } + } + + void _drawDashedLine( + Canvas canvas, + Offset a, + Offset b, + Paint paint, + double phase, + ) { + final double len = (b - a).distance; + if (len <= 0) return; + const double dash = 18.0; + const double gap = 14.0; + final Offset dir = (b - a) / len; + double pos = phase * (dash + gap); + while (pos < len) { + final double segEnd = math.min(pos + dash, len); + canvas.drawLine(a + dir * pos, a + dir * segEnd, paint); + pos += dash + gap; + } + } + + Color _colorByDistance(int? cm) { + if (cm == null) return const Color(0xFF35D07F); // 无测距默认绿 + if (cm < 80) return const Color(0xFFFF4D4D); // 红:危险 + if (cm < 150) return const Color(0xFFFFC61A); // 黄:警告 + return const Color(0xFF35D07F); // 绿:安全 + } + + @override + bool shouldRepaint(covariant _GuidePainter oldDelegate) => + oldDelegate.data.steeringAngle != data.steeringAngle || + oldDelegate.data.rearDistanceCm != data.rearDistanceCm || + oldDelegate.data.gear != data.gear || + oldDelegate.dashPhase != dashPhase; +} diff --git a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart index 1dccb603..121b0e50 100644 --- a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart @@ -27,19 +27,23 @@ class _RightJoystickAreaState extends State { /// 检查是否可以控制,不能则弹出提示 bool _checkCanControl(BuildContext context) { - final cubit = context.read(); - if (!cubit.isStatusPushActive()) { - if (!_hasShownNoPushDialog) { - _hasShownNoPushDialog = true; - _showNoStatusPushDialog(context); - } - return false; - } - _hasShownNoPushDialog = false; + // 🔥 临时关闭「未收到推送不能控制」门槛,便于机器未开时用 Mock 假数据联调倒车影像。 + // 联调完成后取消下面注释即可恢复。切勿删除本段逻辑。 + // final cubit = context.read(); + // if (!cubit.isStatusPushActive()) { + // if (!_hasShownNoPushDialog) { + // _hasShownNoPushDialog = true; + // _showNoStatusPushDialog(context); + // } + // return false; + // } + // _hasShownNoPushDialog = false; return true; } /// 显示暂未收到推送的提示弹窗(小圆角) + /// 🔥 门槛已临时注释,本方法暂未被调用:保留勿删,恢复门槛后即生效。 + // ignore: unused_element void _showNoStatusPushDialog(BuildContext context) { showDialog( context: context, diff --git a/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart b/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart index 393a42f6..98dc6410 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart @@ -7,6 +7,21 @@ import '../../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../../devices/domain/entities/device_entity.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; import '../../../../remote_control/presentation/pages/remote_control_page.dart'; +import '../../../../pv_control/presentation/pages/pv_control_page.dart'; + +/// 远程遥控入口的控制模式(仅本文件使用)。 +/// +/// 保留可扩展:将来要按账号属性动态决定「默认进哪个模式 / 展示哪几个模式」, +/// 只需在此枚举增删值即可,不影响前置信息获取与原极简页逻辑。 +enum _RobotControlMode { + minimal('极简模式', '传统远程遥控界面', Icons.videogame_asset_outlined), + full('全功能模式', '横屏大屏可视化控制', Icons.grid_view_rounded); + + const _RobotControlMode(this.title, this.subtitle, this.icon); + final String title; + final String subtitle; + final IconData icon; +} /// 机器人控制面板 class RobotControlPanel extends StatelessWidget { @@ -17,7 +32,7 @@ class RobotControlPanel extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: () { + onTap: () async { debugPrint('👆 控制面板被点击'); debugPrint('📋 robot 数据: $robot'); @@ -43,22 +58,41 @@ class RobotControlPanel extends StatelessWidget { // 🔥 在跳转前获取 DevicesCubit,避免在 builder 内部 context 找不到 final devicesCubit = context.read(); - // 2. 跳转到远程遥控页面 - debugPrint('🚀 准备跳转...'); - Navigator.push( - context, - MaterialPageRoute( - builder: (newContext) => MultiBlocProvider( - providers: [ - BlocProvider.value( - value: remoteCubit, - ), // 🔥 复用已设置 targetDevice 的单例 - BlocProvider.value(value: devicesCubit), // 🔥 同时提供 DevicesCubit - ], - child: const RemoteControlPage(), + // 2. 前置信息获取保持不变;进控制页前先选模式: + // 极简模式 = 原远程遥控页(RemoteControlPage),全功能模式 = pv_control 大屏。 + final mode = await _showControlModePicker(context); + if (mode == null || !context.mounted) return; // 取消或页面已销毁,不跳转 + + if (mode == _RobotControlMode.full) { + debugPrint('🚀 [控制面板] 进入全功能模式(pv_control)'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PvControlPage( + deviceId: device.deviceName, + deviceName: device.deviceName, + ), ), - ), - ); + ); + } else { + debugPrint('🚀 [控制面板] 进入极简模式(远程遥控页)'); + Navigator.push( + context, + MaterialPageRoute( + builder: (newContext) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: remoteCubit, + ), // 🔥 复用已设置 targetDevice 的单例 + BlocProvider.value( + value: devicesCubit, + ), // 🔥 同时提供 DevicesCubit + ], + child: const RemoteControlPage(), + ), + ), + ); + } }, child: Container( padding: const EdgeInsets.all(12), @@ -77,7 +111,9 @@ class RobotControlPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context).translate('device_list_v2.control_panel'), + AppLocalizations.of( + context, + ).translate('device_list_v2.control_panel'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -105,22 +141,38 @@ class RobotControlPanel extends StatelessWidget { // 上 Positioned( top: 8, - child: _buildDirectionButton(context, Icons.arrow_upward, () {}), + child: _buildDirectionButton( + context, + Icons.arrow_upward, + () {}, + ), ), // 下 Positioned( bottom: 8, - child: _buildDirectionButton(context, Icons.arrow_downward, () {}), + child: _buildDirectionButton( + context, + Icons.arrow_downward, + () {}, + ), ), // 左 Positioned( left: 8, - child: _buildDirectionButton(context, Icons.arrow_back, () {}), + child: _buildDirectionButton( + context, + Icons.arrow_back, + () {}, + ), ), // 右 Positioned( right: 8, - child: _buildDirectionButton(context, Icons.arrow_forward, () {}), + child: _buildDirectionButton( + context, + Icons.arrow_forward, + () {}, + ), ), // 中心按钮 Container( @@ -149,17 +201,49 @@ class RobotControlPanel extends StatelessWidget { const SizedBox(height: 12), // 速度档位 Text( - AppLocalizations.of(context).translate('device_list_v2.speed_gear'), - style: TextStyle(fontSize: 13, color: context.appColors.textTertiary), + AppLocalizations.of( + context, + ).translate('device_list_v2.speed_gear'), + style: TextStyle( + fontSize: 13, + color: context.appColors.textTertiary, + ), ), const SizedBox(height: 12), Row( children: [ - Expanded(child: _buildSpeedButton(context, AppLocalizations.of(context).translate('device_list_v2.speed_low'), false, () {})), + Expanded( + child: _buildSpeedButton( + context, + AppLocalizations.of( + context, + ).translate('device_list_v2.speed_low'), + false, + () {}, + ), + ), const SizedBox(width: 12), - Expanded(child: _buildSpeedButton(context, AppLocalizations.of(context).translate('device_list_v2.speed_mid'), true, () {})), + Expanded( + child: _buildSpeedButton( + context, + AppLocalizations.of( + context, + ).translate('device_list_v2.speed_mid'), + true, + () {}, + ), + ), const SizedBox(width: 12), - Expanded(child: _buildSpeedButton(context, AppLocalizations.of(context).translate('device_list_v2.speed_high'), false, () {})), + Expanded( + child: _buildSpeedButton( + context, + AppLocalizations.of( + context, + ).translate('device_list_v2.speed_high'), + false, + () {}, + ), + ), ], ), ], @@ -168,6 +252,42 @@ class RobotControlPanel extends StatelessWidget { ); } + /// 模式选择弹窗:极简 / 全功能两个选项,选中即返回对应模式, + /// 点外部或返回手势取消则返回 null。选项由枚举驱动,仅本文件使用。 + Future<_RobotControlMode?> _showControlModePicker(BuildContext context) { + return showDialog<_RobotControlMode>( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('选择控制模式'), + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + content: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final m in _RobotControlMode.values) + ListTile( + leading: Icon(m.icon, color: context.appColors.primary), + title: Text( + m.title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: context.appColors.textPrimary, + ), + ), + subtitle: Text( + m.subtitle, + style: TextStyle(color: context.appColors.textTertiary), + ), + onTap: () => Navigator.of(dialogContext).pop(m), + ), + ], + ), + ), + ), + ); + } + Widget _buildDirectionButton( BuildContext context, IconData icon, @@ -216,9 +336,7 @@ class RobotControlPanel extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: isSelected - ? Colors.white - : context.appColors.textSecondary, + color: isSelected ? Colors.white : context.appColors.textSecondary, ), ), ), diff --git a/lib/features/v2/local_camera/data/onvif_client.dart b/lib/features/v2/local_camera/data/onvif_client.dart index d9071174..c99c8d8b 100644 --- a/lib/features/v2/local_camera/data/onvif_client.dart +++ b/lib/features/v2/local_camera/data/onvif_client.dart @@ -6,6 +6,8 @@ import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:xml/xml.dart'; +import 'rtsp_playback_tuning.dart'; + /// 轻量 ONVIF SOAP 客户端。默认账号 admin、密码为空(构造函数可覆盖)。 /// 提供 GetDeviceInformation / GetCapabilities(Media XAddr) / GetProfiles / GetStreamUri。 /// 认证采用 WS-Security UsernameToken + PasswordDigest。 @@ -170,27 +172,17 @@ class OnvifClient { 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 { + /// 按 [select] 挑一个视频 Profile,返回 `(token, 声明码率kbps)`。 + /// + /// 码率一并带出来是给【链路探针】当分母用的:声明码率是“应该收到多少”, + /// 实测进给率是“实际到了多少”,两者一比就能把“图传不够”和“内容坏了”分开。 + /// 设备不报 Bitrate(杂牌机常见)时给 null,探针退成只看比例。 + /// + /// 只报一个 Profile、或压根没报分辨率时,退回第一个(因“选不出”而放弃拉流)。 + Future<(String, int?)> getProfileToken( + String mediaXAddr, + RtspProfileSelect select, + ) async { final body = _envelope( serviceNs: 'http://www.onvif.org/ver10/media/wsdl', actionTail: 'GetProfiles', @@ -200,28 +192,54 @@ class OnvifClient { final resp = await _post(mediaXAddr, body); final doc = XmlDocument.parse(resp); String? firstToken; + int? firstKbps; String? bestToken; + int? bestKbps; int bestArea = -1; + final listed = []; 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; - } + final bitrateKbps = _firstIntIn(p, 'Bitrate'); + if (firstToken == token) firstKbps = bitrateKbps; + listed.add( + '$token ${w ?? '?'}x${h ?? '?'}' + '${bitrateKbps != null ? ' ${bitrateKbps}kbps' : ''}', + ); + if (w == null || h == null || w <= 0 || h <= 0) continue; + if (select == RtspProfileSelect.first) continue; + final area = w * h; + final better = select == RtspProfileSelect.smallest + ? (bestArea < 0 || area < bestArea) + : (bestArea < 0 || area > bestArea); + if (better) { + bestArea = area; + bestToken = token; + bestKbps = bitrateKbps; } } - if (bestToken != null) { - debugPrint('🎚 [子码流] 选用最小分辨率 Profile(面积≈$bestArea)token=$bestToken'); - return bestToken; + if (listed.isNotEmpty) { + debugPrint('🎚 [码流] ${select.label};候选:${listed.join(' | ')}'); } - if (firstToken != null) return firstToken; - throw OnvifException('未获取到视频 Profile'); + if (firstToken == null) throw OnvifException('未获取到视频 Profile'); + // 选不出合适的那一路(设备只报一个 / 没报分辨率 / 选了“设备首个”)→ 退回第一个。 + final String picked; + final int? kbps; + if (select == RtspProfileSelect.first || bestToken == null) { + picked = firstToken; + kbps = firstKbps; + } else { + picked = bestToken; + kbps = bestKbps; + } + debugPrint( + '🎚 [码流] 最终使用 token=$picked(面积≈$bestArea' + '${kbps != null ? ', 声明 ${kbps}kbps' : ', 设备未报码率'})', + ); + return (picked, kbps); } /// 在 [parent] 及其后代中找第一个可解析为 int 的 [name] 元素值。 @@ -252,11 +270,16 @@ class OnvifClient { return _injectCredentials(uri); } - /// 一步到位:由 device xAddr 得到带凭证的 RTSP 地址。 - Future resolveRtsp(String deviceXAddr) async { + /// 一步到位:由 device xAddr 得到带凭证的 RTSP 地址与声明码率。 + /// [profileSelect] 由调参面板控制:取子码流还是主码流。 + Future<(String, int?)> resolveRtsp( + String deviceXAddr, { + RtspProfileSelect profileSelect = RtspProfileSelect.smallest, + }) async { final mediaXAddr = await getMediaXAddr(deviceXAddr); - final token = await getLowLatencyProfileToken(mediaXAddr); - return getStreamUri(mediaXAddr, token); + final (token, bitrateKbps) = await getProfileToken(mediaXAddr, profileSelect); + final uri = await getStreamUri(mediaXAddr, token); + return (uri, bitrateKbps); } /// rtsp://ip/path -> rtsp://admin:pass@ip/path。 diff --git a/lib/features/v2/local_camera/data/rtsp_link_probe.dart b/lib/features/v2/local_camera/data/rtsp_link_probe.dart new file mode 100644 index 00000000..9005bbc2 --- /dev/null +++ b/lib/features/v2/local_camera/data/rtsp_link_probe.dart @@ -0,0 +1,157 @@ +/// 链路进给探针:**判断"图传那一段送过来的数据到底缺不缺"**。 +/// +/// 原理(不需要改原生,也不依赖 TrafficStats): +/// 直播流的 `buffered.last.end` 走在**媒体时间轴**上,它每推进 1ms,就等于 +/// "收到了覆盖 1ms 媒体时长的数据"。用它除以墙钟间隔就是【进给率】: +/// feed = Δbuf / Δt → 数据到得齐不齐(丢帧、断供都会体现在这里) +/// play = Δpos / Δt → 渲染推得推不动(解码/性能问题会体现在这里,测不到则不判定) +/// 25fps 下每丢 1 帧 feed 少约 4%,所以 feed 可以直接当"码流完整度"读。 +/// +/// 为什么不用"积压绝对值"下结论(项目里的历史误报教训):RTP 时间戳与 ExoPlayer +/// 播放时钟存在系统性偏差,画面完全正常时积压也能涨到 4000~6000ms。 +/// 这里 lagMs 只用作**粗分的第三种情况**(货到了没播 vs 根本没到货),阈值放得很宽, +/// 读数不可信时直接置 null(宁可少判一档,也不要用假数下结论)。 +/// +/// ⚠️ play 的输入必须是**原生未钳位的 position**(`await controller.position`): +/// video_player 的 `_updatePosition()` 会把位置钳到 `value.duration`,而 RTSP 直播的 +/// duration 恒为 0 → `value.position` 永远是 0,于是 play 恒为 0%(假"渲染停滞"), +/// 且 lag = bufEnd - 0 会变成"自开播起的总时长"一路虚高。两者是同一个 bug 的两张脸。 +/// +/// 结论边界要说清楚:探针能证明"有没有缺数据",但缺数据的原因(空口误码 / 图传带宽 / +/// 网线接触)它分不出来;"进给完整但仍然花屏"则明确指向**内容本身坏了**, +/// 也就是摄像头编码或空口误码已经被拼进码流,这时 TCP 开关是救不了的。 +enum LinkVerdict { + unknown( + '采样中', + '刚开始或读数不可信,等几秒再下结论。', + ), + healthy( + '进给完整', + '数据基本按实时到齐。若画面仍花,病不在"传得不够",而在收到的码流本身有错' + '(空口误码已拼进流 / 编码)或解码层——切软解对比,或在摄像头端查 GOP。', + ), + lightLoss( + '轻度丢帧', + '有丢帧但量不大:先切 TCP 看能否靠重传补回(补得回=链路在丢包但带宽够);' + '补不回就降码流。', + ), + starved( + '断供', + '进给明显低于实时:链路吞吐不足或严重丢包,指向图传空口段。' + 'APP 侧只能降码流(子码流 / 摄像头端调低码率、缩短 GOP),TCP 只会更卡。', + ), + decoderStalled( + '渲染停滞', + '缓冲里还有货但播不动:解码/渲染跟不上,不是链路问题。' + '切软解或降码流对比。(仅在 position 读数可信时才会给出这一档)', + ), + backlogGrowing( + '积压增长', + '缓冲末尾推进快于墙钟,积压持续变大(端到端延迟会一路上涨):媒体时间轴/推流偏差,' + '或播放侧跟不上。RTSP 是不可 seek 的直播源,ExoPlayer 不会丢旧数据追实时边缘,' + '所以唯一的止血办法是把 maxBufferMs 压小(Loader 到 max 就不许再囤)。', + ); + + const LinkVerdict(this.label, this.advice); + final String label; + final String advice; +} + +/// 一次探针读数。 +class RtspLinkSnapshot { + const RtspLinkSnapshot({ + required this.windowMs, + required this.feed, + required this.play, + required this.lagMs, + required this.verdict, + this.declaredKbps, + }); + + final int windowMs; + + /// 数据进给率(1.0 = 完全按实时到达)。 + final double feed; + + /// 渲染推进率(1.0 = 画面按实时在走)。null = 本流/本机型拿不到可信的 position, + /// 这一档直接不参与判定(绝不能再拿它当 0% 看)。 + final double? play; + + /// 缓冲末尾与播放位置之差(仅作粗分依据,不当延迟结论用)。null = 不可信。 + final int? lagMs; + + final LinkVerdict verdict; + + /// ONVIF Profile 声明的码率(kbps),设备没报则为 null。 + final int? declaredKbps; + + /// 估算丢帧率(%):feed 低于 1 的部分。 + int get estLossPct => ((1 - feed) * 100).clamp(0, 100).round(); + + /// 按"每 ms 媒体时长对应等量字节"估算的实际接收速率(kbps)。 + /// 仅在有声明码率时给出,属于估算而非计量。 + int? get estReceivedKbps => + declaredKbps == null ? null : (declaredKbps! * feed).round(); + + /// 一行数字摘要(显示在链路条上方)。 + String get detail { + final double? p = play; + final int? lag = lagMs; + final String base = + '进给 ${(feed * 100).toStringAsFixed(0)}%' + ' · 渲染 ${p == null ? '—' : '${(p * 100).toStringAsFixed(0)}%'}' + ' · 积压 ${lag == null ? '—' : '${lag}ms'}'; + final int? kbps = estReceivedKbps; + if (kbps == null) return base; + return '$base · 实收≈${(kbps / 1000).toStringAsFixed(2)}M' + '/声明${(declaredKbps! / 1000).toStringAsFixed(2)}M'; + } +} + +/// 用两个相邻采样的增量算出探针读数。窗口不足或读数可疑时返回 null(不判定)。 +/// +/// [dPosMs] / [lagMs] 传 null 表示调用方确认这两个读数不可信(比如 position 拿不到), +/// 此时仅依 feed 判定,不再给出 `渲染停滞`。 +RtspLinkSnapshot? evaluateLinkSnapshot({ + required int dtMs, + int? dPosMs, + required int dBufMs, + int? lagMs, + int? declaredKbps, +}) { + // 第一个窗口没有增量;窗口太短的话时间戳抖动会盖过信号。 + if (dtMs < 1500) return null; + // 缓冲末尾回退:重拉流/时间戳重置,这一轮作废。 + if (dBufMs < -50) return null; + final double feed = (dBufMs / dtMs).clamp(0.0, 3.0); + final double? play = dPosMs == null ? null : (dPosMs / dtMs).clamp(0.0, 3.0); + final int? lag = lagMs; + + // 关键分叉:feed 掉下来了,到底是"没到货"还是"货压着没播"? + // 手里还有明显积压却播不动 → 判解码/渲染;积压快见底 → 判链路断供。 + // 没有可信的 position / lag 读数时,这一分叉直接不走(宁可少判一档)。 + final bool renderStalled = play != null && play < 0.60; + final bool nearlyEmpty = lag != null && lag < 400; + + final LinkVerdict verdict; + if (renderStalled && lag != null && !nearlyEmpty) { + verdict = LinkVerdict.decoderStalled; + } else if (feed > 1.08) { + verdict = LinkVerdict.backlogGrowing; + } else if (feed < 0.85) { + verdict = LinkVerdict.starved; + } else if (feed < 0.97) { + verdict = LinkVerdict.lightLoss; + } else { + verdict = LinkVerdict.healthy; + } + + return RtspLinkSnapshot( + windowMs: dtMs, + feed: feed, + play: play, + lagMs: lagMs, + verdict: verdict, + declaredKbps: declaredKbps, + ); +} diff --git a/lib/features/v2/local_camera/data/rtsp_playback_tuning.dart b/lib/features/v2/local_camera/data/rtsp_playback_tuning.dart new file mode 100644 index 00000000..3fd5ee1c --- /dev/null +++ b/lib/features/v2/local_camera/data/rtsp_playback_tuning.dart @@ -0,0 +1,338 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 解码方式(对应原生 PlaybackTuning.decoderMode)。 +/// +/// Media3 不传 RenderersFactory 时 = `DefaultRenderersFactory` + `MediaCodecSelector.DEFAULT`, +/// 即**硬件优先**,而且硬解初始化失败**不会**自动回退软解(直接报错)。 +enum RtspDecoderMode { + hardwarePreferred(0, '硬解', 'Media3 默认:硬件解码器优先'), + softwareOnly(1, '软解', '只用软件解码:排除硬解差异,功耗高、高分辨率可能跟不上'), + hardwareWithFallback(2, '硬解+回退', '硬件优先,初始化失败时回退软解(缺对应硬解的机型有用)'); + + const RtspDecoderMode(this.nativeValue, this.label, this.desc); + + final int nativeValue; + final String label; + final String desc; + + static RtspDecoderMode fromName(String? name) { + return RtspDecoderMode.values.firstWhere( + (m) => m.name == name, + orElse: () => hardwarePreferred, + ); + } + + /// 从原生回显的 int 还原(回显里没有 name,只有 nativeValue)。 + static RtspDecoderMode fromNative(int? value) { + return RtspDecoderMode.values.firstWhere( + (m) => m.nativeValue == value, + orElse: () => hardwarePreferred, + ); + } +} + +/// 取哪个 ONVIF Profile —— 这是 App 侧唯一能"调码率"的抓手。 +/// +/// App 不改摄像头编码配置(那要 SetVideoEncoderConfiguration,会写设备), +/// 而是换一条已配好的流:子码流低分辨率低码率、主码流反之。 +enum RtspProfileSelect { + smallest('子码流', '分辨率最小的 Profile(默认,省带宽)'), + largest('主码流', '分辨率最大的 Profile(验证是不是码率超出图传吞吐)'), + first('设备首个', 'GetProfiles 返回的第一个(很多机型=主码流)'); + + const RtspProfileSelect(this.label, this.desc); + + final String label; + final String desc; + + static RtspProfileSelect fromName(String? name) { + return RtspProfileSelect.values.firstWhere( + (p) => p.name == name, + orElse: () => smallest, + ); + } +} + +/// 直播播放调参(与本地覆盖包 packages/video_player_android 的 PlaybackTuning.java 对应)。 +/// +/// 用途:把「治花屏」和「治延迟」两个互相冲突的策略做成**运行时可改的选项**, +/// 现场 A/B 不需要重新编译。改动只对**下一次拉流**生效(ExoPlayer 创建时读取)。 +/// +/// 两个策略的取舍(源码依据见各字段注释): +/// - **花屏** ← 空口/图传丢包。UDP 不重传,坏帧直接解出马赛克;TCP 会重传把画面补全, +/// 代价是弱链路下卡顿、延迟上升。 +/// - **延迟** ← ExoPlayer 稳态积压。Media3 `DefaultLoadControl` 的 Loader 在 +/// `minBufferMs..maxBufferMs` 之间锯齿,平均积压约为 `(min+max)/2`; +/// `bufferForPlaybackMs=0` 只决定「起播不等缓冲」,不压稳态延迟。 +class RtspPlaybackTuning { + const RtspPlaybackTuning({ + this.forceRtpTcp = false, + this.minBufferMs = 500, + this.maxBufferMs = 3000, + this.bufferForPlaybackMs = 0, + this.bufferForPlaybackAfterRebufferMs = 0, + this.rtspTimeoutMs = 0, + this.targetBufferBytes = 0, + this.decoderMode = RtspDecoderMode.hardwarePreferred, + this.profileSelect = RtspProfileSelect.smallest, + }); + + /// RTSP 的 RTP 传输方式。 + /// false = UDP(Media3 默认,延迟低,丢包即花屏);true = TCP interleaved(抗丢包,代价是卡)。 + final bool forceRtpTcp; + + /// Loader 重新恢复加载的水位(毫秒)。 + /// 注意:Media3 内部有 `minBufferUs = max(minBufferUs, 500_000)` 的硬下限, + /// 填小于 500 的值**不会有任何效果**,UI 上要把这一点告诉用户。 + final int minBufferMs; + + /// Loader 停止加载的水位(毫秒)= 稳态积压上限,延迟最直接的可调项。 + final int maxBufferMs; + + /// 起播/暂停恢复所需缓冲(毫秒)。直播保持 0,首帧到达即渲染。 + final int bufferForPlaybackMs; + + /// rebuffer 后恢复播放所需缓冲(毫秒)。直播保持 0,避免每次卡完再攒一段。 + final int bufferForPlaybackAfterRebufferMs; + + /// RtspMediaSource 超时(毫秒)。0 = 用 Media3 默认值。 + /// 它同时影响「UDP 长时间无数据后改用 TCP 重试」与「多久无数据判为流结束」, + /// 设太小会在弱链路上误判断流。 + final int rtspTimeoutMs; + + /// 字节上限 `targetBufferBytes`。0 = 不覆盖,用 Media3 默认(视频约 2MB)。 + /// 加载器是「时间水位到了**或**字节数到了」就停:子码流下 2MB 等于十几秒、轮不到它; + /// 换主码流/高码率后 2MB 可能不到 1 秒就撞上 → 表现为频繁 rebuffer,需调大。 + /// 注:这就是当年 media_kit 那个 32MB bufferSize 在 ExoPlayer 里的对应项。 + final int targetBufferBytes; + + /// 解码方式(硬解/软解/硬解失败回退软解)。 + final RtspDecoderMode decoderMode; + + /// 取主码流还是子码流(在 ONVIF 取流那一步生效,猜 URL 兜底路径不受它控制)。 + final RtspProfileSelect profileSelect; + + static const RtspPlaybackTuning defaults = RtspPlaybackTuning(); + + // ===== 预设档位(现场试验直接点,不用逐个填数字)===== + + /// 均衡:TCP 补画面 + 中等水位。图传链路上推荐先从这里开始试。 + static const RtspPlaybackTuning balanced = RtspPlaybackTuning( + forceRtpTcp: true, + maxBufferMs: 1500, + ); + + /// 抗花屏优先:TCP + 原水位。画面完整性优先,能接受卡顿。 + static const RtspPlaybackTuning antiArtifact = RtspPlaybackTuning( + forceRtpTcp: true, + maxBufferMs: 3000, + ); + + /// 低延迟优先:UDP + 紧水位。链路质量好、要最跟手时用。 + static const RtspPlaybackTuning lowLatency = RtspPlaybackTuning( + forceRtpTcp: false, + maxBufferMs: 1000, + ); + + /// 稳态积压估算(毫秒):Loader 锯齿的中点。 + int get estimatedBacklogMs => (minBufferMs + maxBufferMs) ~/ 2; + + /// 当前是否处于「Media3 原生默认行为」——用于 UI 标记未改动。 + bool get isDefault => + forceRtpTcp == defaults.forceRtpTcp && + minBufferMs == defaults.minBufferMs && + maxBufferMs == defaults.maxBufferMs && + bufferForPlaybackMs == defaults.bufferForPlaybackMs && + bufferForPlaybackAfterRebufferMs == defaults.bufferForPlaybackAfterRebufferMs && + rtspTimeoutMs == defaults.rtspTimeoutMs && + targetBufferBytes == defaults.targetBufferBytes && + decoderMode == defaults.decoderMode && + profileSelect == defaults.profileSelect; + + /// 给页面显示的一行摘要(含策略含义,省得对着数字猜)。 + /// + /// targetBufferBytes 是字节上限,它和 maxBufferMs 哪个先到就按哪个停, + /// 所以**它一旦被改大,延迟上限会跟着抬高**——这个字段以前不在摘要里, + /// 导致“今天怎么变慢了”完全看不出来,现在只要不是默认值就显式标出来。 + String get summary { + final String bytesPart = targetBufferBytes == defaults.targetBufferBytes + ? '' + : ' · 字节上限${targetBufferBytes ~/ 1024}KB'; + return '${forceRtpTcp ? 'TCP' : 'UDP'} · ' + '水位 $minBufferMs~${maxBufferMs}ms(≈$estimatedBacklogMs) · ' + '${profileSelect.label} · ${decoderMode.label}$bytesPart'; + } + + RtspPlaybackTuning copyWith({ + bool? forceRtpTcp, + int? minBufferMs, + int? maxBufferMs, + int? bufferForPlaybackMs, + int? bufferForPlaybackAfterRebufferMs, + int? rtspTimeoutMs, + int? targetBufferBytes, + RtspDecoderMode? decoderMode, + RtspProfileSelect? profileSelect, + }) { + return RtspPlaybackTuning( + forceRtpTcp: forceRtpTcp ?? this.forceRtpTcp, + minBufferMs: minBufferMs ?? this.minBufferMs, + maxBufferMs: maxBufferMs ?? this.maxBufferMs, + bufferForPlaybackMs: bufferForPlaybackMs ?? this.bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs: + bufferForPlaybackAfterRebufferMs ?? this.bufferForPlaybackAfterRebufferMs, + rtspTimeoutMs: rtspTimeoutMs ?? this.rtspTimeoutMs, + targetBufferBytes: targetBufferBytes ?? this.targetBufferBytes, + decoderMode: decoderMode ?? this.decoderMode, + profileSelect: profileSelect ?? this.profileSelect, + ); + } + + /// 下发给原生侧的参数。`max < min` 这类非法组合在这里先夹住: + /// Media3 构造函数对 maxBufferMs >= minBufferMs 有断言,越界会直接抛异常表现为首帧黑屏。 + /// 注意:profileSelect 不出现在这里——它作用于 ONVIF 取流(Dart 侧),不是 ExoPlayer 参数。 + Map toChannelArgs() { + final int min = minBufferMs < 500 ? 500 : minBufferMs; // Media3 硬下限 + final int max = maxBufferMs < min ? min : maxBufferMs; + return { + 'forceRtpTcp': forceRtpTcp, + 'minBufferMs': min, + 'maxBufferMs': max, + 'bufferForPlaybackMs': bufferForPlaybackMs, + 'bufferForPlaybackAfterRebufferMs': bufferForPlaybackAfterRebufferMs, + 'rtspTimeoutMs': rtspTimeoutMs, + 'targetBufferBytes': targetBufferBytes < 0 ? 0 : targetBufferBytes, + 'decoderMode': decoderMode.nativeValue, + }; + } + + /// 持久化用:枚举存 name(存 index 会因枚举重排而错位)。 + Map toJson() => { + ...toChannelArgs(), + 'profileSelect': profileSelect.name, + 'decoderModeName': decoderMode.name, + }; + + factory RtspPlaybackTuning.fromMap(Map m) { + return RtspPlaybackTuning( + forceRtpTcp: (m['forceRtpTcp'] as bool?) ?? defaults.forceRtpTcp, + minBufferMs: (m['minBufferMs'] as num?)?.toInt() ?? defaults.minBufferMs, + maxBufferMs: (m['maxBufferMs'] as num?)?.toInt() ?? defaults.maxBufferMs, + bufferForPlaybackMs: + (m['bufferForPlaybackMs'] as num?)?.toInt() ?? defaults.bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs: + (m['bufferForPlaybackAfterRebufferMs'] as num?)?.toInt() ?? + defaults.bufferForPlaybackAfterRebufferMs, + rtspTimeoutMs: (m['rtspTimeoutMs'] as num?)?.toInt() ?? defaults.rtspTimeoutMs, + targetBufferBytes: (m['targetBufferBytes'] as num?)?.toInt() ?? defaults.targetBufferBytes, + // 缓存里存 name,原生回显里只有 int:两种来源都要能解。 + decoderMode: m['decoderModeName'] != null + ? RtspDecoderMode.fromName(m['decoderModeName'] as String?) + : RtspDecoderMode.fromNative((m['decoderMode'] as num?)?.toInt()), + profileSelect: RtspProfileSelect.fromName(m['profileSelect'] as String?), + ); + } + + // ===== 下发到原生插件 ===== + + static const MethodChannel _channel = + MethodChannel('plugins.flutter.dev/video_player_android/tuning'); + + /// 把当前配置下发给插件。**必须在 `VideoPlayerController.initialize()` 之前调用**, + /// 因为 LoadControl / RtspMediaSource.Factory 都是在创建播放器那一刻读值的。 + Future apply() async { + try { + final Map? echoed = + await _channel.invokeMethod>('set', toChannelArgs()); + debugPrint( + '🛠 [调参] 已下发 $summary → 原生回显 ${echoed?['maxBufferMs']}ms 水位 / ' + 'TCP=${echoed?['forceRtpTcp']}', + ); + } on PlatformException catch (e) { + debugPrint('🛠 [调参] 下发失败:${e.code} ${e.message}'); + } on MissingPluginException { + // 非 Android 平台或未使用本地覆盖包(例如误装了 pub.dev 的 video_player_android)。 + debugPrint('🛠 [调参] 未找到调参通道:确认本地覆盖包已生效(dependency_overrides)'); + } + } + + /// 读回原生侧当前真正生效的值,用于「改了参数到底有没有进插件」的定性验证。 + static Future readBack() async { + try { + final Map? m = + await _channel.invokeMethod>('get'); + if (m == null) return null; + return RtspPlaybackTuning.fromMap( + m.map((k, v) => MapEntry('$k', v)), + ); + } catch (_) { + return null; + } + } + + // ===== 持久化(跨启动保留,方便反复折腾对比)===== + + static const String _prefsKey = 'local_camera_playback_tuning'; + + Future save() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, jsonEncode(toJson())); + } catch (_) {} + } + + static Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null || raw.isEmpty) return defaults; + final m = (jsonDecode(raw) as Map).cast(); + return RtspPlaybackTuning.fromMap(m); + } catch (_) { + return defaults; + } + } + + Future clearSaved() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); + } catch (_) {} + } + + /// 值相等:State 的 Equatable 比较、预设档位的选中高亮都依赖它, + /// 否则从缓存读回来的同参配置会被当成“另一个对象”而多刷一次屏。 + @override + bool operator ==(Object other) => + identical(this, other) || + other is RtspPlaybackTuning && + other.forceRtpTcp == forceRtpTcp && + other.minBufferMs == minBufferMs && + other.maxBufferMs == maxBufferMs && + other.bufferForPlaybackMs == bufferForPlaybackMs && + other.bufferForPlaybackAfterRebufferMs == bufferForPlaybackAfterRebufferMs && + other.rtspTimeoutMs == rtspTimeoutMs && + other.targetBufferBytes == targetBufferBytes && + other.decoderMode == decoderMode && + other.profileSelect == profileSelect; + + @override + int get hashCode => Object.hash( + forceRtpTcp, + minBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + rtspTimeoutMs, + targetBufferBytes, + decoderMode, + profileSelect, + ); + + @override + String toString() => summary; +} 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 index 8d701f06..411e719c 100644 --- a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:video_player/video_player.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -11,26 +12,41 @@ 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_link_probe.dart'; +import '../../data/rtsp_playback_tuning.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: ''), +/// +/// 生命周期由本类自己接管([WidgetsBindingObserver]):退后台 pause、回前台**重拉流**。 +/// 原因见 [didChangeAppLifecycleState] 里的 `seekTo(0)` 陷阱,这个不能交给插件默认行为。 +class LocalCameraCubit extends Cubit + with WidgetsBindingObserver { + /// [autoPlayFirst]:发现到摄像头后自动选第一路拉流(免手点,供“自动获取”场景)。 + /// [autoRecover]:断流 / 缓冲卡死时不留在“请重新连接”,延时自动重拉流。 + LocalCameraCubit({ + OnvifClient? client, + this.autoPlayFirst = false, + this.autoRecover = false, + }) : _client = client ?? OnvifClient(username: 'admin', password: ''), super(const LocalCameraState()); final OnvifDiscoveryService _discovery = OnvifDiscoveryService(); final OnvifClient _client; + final bool autoPlayFirst; + final bool autoRecover; + Timer? _recoverTimer; /// 网段扫描兜底(组播失败时不依赖组播,直接 TCP 探设 ONVIF 设备)。 late final OnvifSubnetScanner _scanner = OnvifSubnetScanner(client: _client); // ===== 播放器(video_player / Android=ExoPlayer)===== - // ExoPlayer 原生支持 RTSP(RtspMediaSource),默认走 TCP 交错、缓冲小,天然低延迟, - // 不需要 mpv 那套 cache/demuxer 手动调参。 + // ExoPlayer 原生支持 RTSP(RtspMediaSource)。注意:Media3 的 RTP 传输**默认是 UDP 单播**, + // 只在“播放未开始且长时间收不到 RTP”时才回退 TCP 交错;要强制 TCP 必须显式 setForceUseRtpTcp(true)。 + // 两者已做成运行时可调项(见 RtspPlaybackTuning / 页面调参条)。 VideoPlayerController? _videoController; VoidCallback? _controllerListener; @@ -64,6 +80,19 @@ class LocalCameraCubit extends Cubit { // 延迟,绝不能拿它弹“解码跟不上推流”这种提示(曾经就这样误报过)。 // 真断流自有 hasError 与 isBuffering 的 12s stallTimer 负责,这里只留日志看趋势。 Timer? _lagTimer; + + // ===== 链路探针:用上面的增量直接算出“送过来的数据缺不缺”,结论上屏 ===== + // 不依赖原生字节计量(TrafficStats 对 USB 以太网网卡不一定统计得到), + // 只拿已经有的 position / buffered 两个读数做差,所以改完热重启即可生效。 + // 用 ValueNotifier 而不是放进 state:每 2s 刷一次读数的重建只发生在探针那一行。 + final ValueNotifier _probe = + ValueNotifier(null); + + /// 链路探针读数(UI 用 ValueListenableBuilder 监听)。 + ValueListenable get probe => _probe; + + /// ONVIF 声明码率(kbps),由取流那一步带出来,给探针当分母。设备未报则 null。 + int? _declaredKbps; // 推进速率诊断:同一个积压数字可能是两种完全相反的病因—— // pos 推进 ≪ 墙钟 → 解码/渲染停滞(播放追不上) // buf 推进 > 墙钟 → 媒体时间轴/推流异常 @@ -76,7 +105,9 @@ class LocalCameraCubit extends Cubit { /// 页面进入时调用:检查网络并自动搜索。 Future start() async { + WidgetsBinding.instance.addObserver(this); // close() 里成对移除 _listenConnectivity(); + await _loadTuning(); await _checkNetworkAndDiscover(); } @@ -165,6 +196,10 @@ class LocalCameraCubit extends Cubit { message: null, ), ); + // 自动获取模式:不等手点,直接选第一路拉流 + if (autoPlayFirst && enriched.isNotEmpty) { + unawaited(selectCamera(enriched.first)); + } } catch (e) { emit( state.copyWith( @@ -180,6 +215,7 @@ class LocalCameraCubit extends Cubit { /// 候选顺序:① ONVIF 正规 GetStreamUri 结果 → ② RtspUrlGuesser 常见品牌模板。 Future selectCamera(OnvifCamera cam) async { _userStopped = false; + _recoverTimer?.cancel(); emit( state.copyWith( phase: LocalCameraPhase.connecting, @@ -198,10 +234,19 @@ class LocalCameraCubit extends Cubit { // 1) 先按 ONVIF 正道取流(能拿到就放到候选第一位) final urls = []; String? onvifUrl; + _declaredKbps = null; try { - onvifUrl = await _client.resolveRtsp(cam.xAddr); + final (String uri, int? kbps) = await _client.resolveRtsp( + cam.xAddr, + profileSelect: _tuning.profileSelect, + ); + onvifUrl = uri; + _declaredKbps = kbps; urls.add(onvifUrl); - debugPrint('🎬 [拉流] ONVIF 解析成功:$onvifUrl'); + debugPrint( + '🎬 [拉流] ONVIF 解析成功:$onvifUrl' + '${kbps != null ? '(声明 ${kbps}kbps)' : ''}', + ); } catch (e) { debugPrint('🎬 [拉流] ONVIF 未给出 RTSP:$e'); } @@ -263,14 +308,25 @@ class LocalCameraCubit extends Cubit { showReconnect: true, ), ); + _scheduleRecover(const Duration(seconds: 5)); } /// 打开单个候选 URL,[timeout] 内完成 initialize() 且首帧就绪即视为成功。 - /// ExoPlayer 对 RTSP 直播默认走 TCP 交错、缓冲小;失败/超时则 dispose 后返回 false。 + /// 失败/超时则 dispose 后返回 false。传输方式(UDP/TCP)与缓冲水位在此刻由原生侧读调参配置决定。 Future _tryPlay(String url, {required Duration timeout}) async { VideoPlayerController? c; try { - c = VideoPlayerController.networkUrl(Uri.parse(url)); + // 调参必须在 initialize 之前下发:LoadControl 与 RtspMediaSource.Factory 都是 + // 在创建播放器那一刻读值,晚一步本轮拉流就用的是旧参数。 + await _tuning.apply(); + c = VideoPlayerController.networkUrl( + Uri.parse(url), + // 关键:关掉插件内置的生命周期观察者,改由本类的 didChangeAppLifecycleState 接管。 + // 不设这一项的话,回前台那次 play() 会把直播 seek 回缓冲最前端(延迟棘轮)。 + videoPlayerOptions: VideoPlayerOptions( + allowBackgroundPlayback: true, + ), + ); await c.initialize().timeout(timeout); // 直播流 initialize 完成即已连上并拿到分辨率;立即起播 await c.play(); @@ -332,7 +388,11 @@ class LocalCameraCubit extends Cubit { _controllerListener = null; try { await c.dispose(); - } catch (_) {} + } catch (e) { + // 这里不能吞:原生没 release 就不会发 RTSP TEARDOWN,摄像头侧的会话会越积越多, + // 表现为“越播越慢”,网线直连的单播杂牌机尤其常见(并发会话路数受限)。 + debugPrint('🧹 [释放] controller.dispose 失败(RTSP 会话可能未 TEARDOWN):$e'); + } } } @@ -341,51 +401,131 @@ class LocalCameraCubit extends Cubit { _openWatchdog = null; } + // ===== 生命周期:后台必须自己管,不能留给 video_player 的默认行为 ===== + // 插件内置观察者(allowBackgroundPlayback=false 时创建)在 resumed 调 play(), + // 而 play() 第一行是: + // if (value.position == value.duration) await seekTo(Duration.zero); + // 直播的 duration 恒为 0,position 又被 _updatePosition 钳到 0 → **条件恒成立**, + // 于是每次回前台都会被 seek 到【缓冲里最旧的那一帧】。RTSP 源 isSeekable=false 且没有 + // LiveConfiguration(BEHIND_LIVE_WINDOW 那条追赶分支根本不会触发),只能 1x + // 从最旧帧重播 → 延迟当场变成“当时已缓冲的总时长”,且**只增不减(棘轮)**。 + // 这就是“卡了一下、重进来就没之前快了”的机制。所以这里显式关掉内置观察者, + // 后台只 pause(pause 不带 seek,安全),回前台直接重建控制器重拉流把时间轴清零。 + bool _resumeOnForeground = false; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // 参数必须叫 state(重写方法不得改名),所以下面访问 Cubit 自己的状态时必须带 this.。 + final VideoPlayerController? c = _videoController; + if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden) { + if (c != null && + !c.value.hasError && + this.state.phase == LocalCameraPhase.playing && + c.value.isPlaying) { + _resumeOnForeground = true; + unawaited(c.pause()); + // 后台期间不要把“断流判定/采样”继续跑:stallTimer 会把正常退后台误判成掉线, + // 而跨过后台的增量做差得出的进给/积压读数必然是假的。 + _stallTimer?.cancel(); + _stallTimer = null; + _stopLagWatchdog(); + debugPrint('🧊 [生命周期] 退后台:已暂停(回前台将重拉流,不进 seek 陷阱)'); + } + return; + } + if (state == AppLifecycleState.resumed && _resumeOnForeground) { + _resumeOnForeground = false; + if (_userStopped || isClosed) return; + // 同一条路径同时间只能跑一遍:重拉会 dispose + 新建控制器, + // 两遍同时跑会互相踩掉对方的 controller(表现为一闪就黑、或留下未释放的会话)。 + if (this.state.phase == LocalCameraPhase.connecting) return; + debugPrint('🔥 [生命周期] 回前台:重拉流以回到实时边缘'); + unawaited(reapplyAndReplay()); + } + // inactive / detached 刻意不管:分屏、下拉通知栏、锁屏动画都会发 inactive, + // 据此重拉流只会把正常观看打断。 + } + // ===== 延迟水位观测 ===== void _startLagWatchdog() { _lagTimer?.cancel(); _prevPosMs = -1; _prevBufMs = -1; _prevSampleAtMs = 0; + _probe.value = null; // 重拉流后时间轴从头开始,旧读数一律作废 _lagTimer = Timer.periodic( const Duration(seconds: 2), - (_) => _checkLagWatermark(), + (_) => unawaited(_checkLagWatermark()), ); } void _stopLagWatchdog() { _lagTimer?.cancel(); _lagTimer = null; + _probe.value = null; } /// 只读不回写:“缓冲末尾 - 播放位置”= 播放器内部积压(直播时间轴从 0 起算)。 - void _checkLagWatermark() { + /// + /// ⚠️ position 一律走 `await c.position`(直连原生 getCurrentPosition), + /// 不能用 `c.value.position`:video_player 的 `_updatePosition()` 会把上报位置 + /// **钳到 value.duration**,而 RTSP 直播的 duration == Duration.zero,于是 + /// `value.position` 永远是 0:探针的“渲染”会恒为 0%(误报渲染停滞), + /// “积压”会等于 buffer 末尾本身(自开播算起的总时长)一路虚高。 + Future _checkLagWatermark() async { final c = _videoController; if (c == null || _userStopped || isClosed) return; if (state.phase != LocalCameraPhase.playing || !c.value.isInitialized) return; if (c.value.buffered.isEmpty) return; final int bufferedEndMs = c.value.buffered.last.end.inMilliseconds; - final int positionMs = c.value.position.inMilliseconds; - // 直播流的缓冲位置可能取到 C.POSITION_UNSET(Long.MIN_VALUE),经 Duration 转换后 - // 会变成荒谬值,只接受合理区间内的读数。 - if (bufferedEndMs <= 0 || positionMs < 0) return; - final int lagMs = bufferedEndMs - positionMs; - if (lagMs < 0 || lagMs > _lagSaneMaxMs) { - debugPrint('⏱ [延迟水位] 读数异常(lag=${lagMs}ms),本轮不判定'); - return; + if (bufferedEndMs <= 0) return; + + // 原生读数:拿不到(已销毁/异常/荒谬值)就标记不可信,本轮只用 feed。 + int rawPosMs = -1; + try { + final Duration? p = await c.position; + if (p != null) rawPosMs = p.inMilliseconds; + } catch (_) { + rawPosMs = -1; } + // await 期间可能已经停播/关页:_probe 一旦 dispose 就不能再写。 + if (_userStopped || isClosed) return; + final bool posUsable = + rawPosMs >= 0 && rawPosMs <= _lagSaneMaxMs * 200; // 上限防住 TIME_UNSET 类荒谬值 + // 积压只在两个读数都合理时才给;超出合理区间当“不可信”处理,不因此中断采样。 + final int lagRaw = posUsable ? bufferedEndMs - rawPosMs : -1; + final int? lagMs = (lagRaw >= 0 && lagRaw <= _lagSaneMaxMs) ? lagRaw : null; + final int nowMs = DateTime.now().millisecondsSinceEpoch; final int dtMs = _prevSampleAtMs == 0 ? 0 : nowMs - _prevSampleAtMs; - final int dPos = _prevPosMs < 0 ? 0 : positionMs - _prevPosMs; + int? dPos = !posUsable || _prevPosMs < 0 ? null : rawPosMs - _prevPosMs; final int dBuf = _prevBufMs < 0 ? 0 : bufferedEndMs - _prevBufMs; - _prevPosMs = positionMs; + if (dPos != null && dPos < -50) dPos = null; // 时间轴重置,这一轮不判渲染 + _prevPosMs = posUsable ? rawPosMs : -1; _prevBufMs = bufferedEndMs; _prevSampleAtMs = nowMs; debugPrint( - dtMs == 0 - ? '⏱ [延迟水位] 积压=${lagMs}ms pos=${positionMs}ms buf=${bufferedEndMs}ms' - : '⏱ [延迟水位] 积压=${lagMs}ms | ${dtMs}ms 内 pos推进=${dPos}ms · buf推进=${dBuf}ms', + '⏱ [延迟水位] ${lagMs == null ? '积压=不可信' : '积压=${lagMs}ms'}' + ' pos=${posUsable ? '${rawPosMs}ms' : '不可信'} buf=${bufferedEndMs}ms' + '${dtMs == 0 ? '' : ' | ${dtMs}ms 内 pos推进=${dPos == null ? '—' : '${dPos}ms'} · buf推进=${dBuf}ms'}', ); + // 同上:绝对值不当判据,但【增量】是可信的,交给链路探针做分类。 + final RtspLinkSnapshot? snap = evaluateLinkSnapshot( + dtMs: dtMs, + dPosMs: dPos, + dBufMs: dBuf, + lagMs: lagMs, + declaredKbps: _declaredKbps, + ); + if (snap != null) { + _probe.value = snap; + debugPrint( + '📡 [链路探针] ${snap.verdict.label}|${snap.detail}' + '${snap.estLossPct > 0 ? '|丢帧≈${snap.estLossPct}%' : ''}' + '|${snap.verdict.advice}', + ); + } // 注意:此处故意不对 lagMs 做任何“失败”判定,也绝不 emit disconnected。 // 积压读数受媒体时间轴偏差影响,画面正常时也能涨到几千毫秒,据此提示只会误报。 } @@ -400,6 +540,24 @@ class LocalCameraCubit extends Cubit { showReconnect: true, ), ); + // 卡顿(缓冲进度 0% 超 12s)/断流自动刷新:延时重拉,替代手动点“重新连接” + _scheduleRecover(const Duration(seconds: 3)); + } + + /// 自动恢复调度:仅 [autoRecover] 开启且用户未主动停播时生效。 + void _scheduleRecover(Duration delay) { + if (!autoRecover || _userStopped || isClosed) return; + _recoverTimer?.cancel(); + _recoverTimer = Timer(delay, () { + if (isClosed || _userStopped) return; + final cam = state.selected; + debugPrint('♻️ [自动恢复] ${cam != null ? '重拉流:${cam.xAddr}' : '无选中摄像头,重新搜索'}'); + if (cam != null) { + unawaited(selectCamera(cam)); + } else { + unawaited(_checkNetworkAndDiscover()); + } + }); } /// 点击"重新连接":有选中摄像头则重拉流,否则重新搜索。 @@ -412,6 +570,96 @@ class LocalCameraCubit extends Cubit { await selectCamera(cam); } + // ===== 直播调参(花屏 / 延迟现场 A/B)===== + RtspPlaybackTuning _tuning = RtspPlaybackTuning.defaults; + + /// 当前生效的调参(页面回显用)。 + RtspPlaybackTuning get tuning => _tuning; + + /// 读回原生插件里**真正生效**的值,用于确认“改了参数到底有没有进插件”。 + Future readBackTuning() => RtspPlaybackTuning.readBack(); + + /// 进入页面:恢复上次调参并先下发一次(保证首拉就用对参数)。 + Future _loadTuning() async { + _tuning = await RtspPlaybackTuning.load(); + emit(state.copyWith(tuning: _tuning)); + await _tuning.apply(); + } + + /// 保存 + 下发一组调参。[reconnect] 为 true 且已在播/曾出画面时, + /// 直接用**当前已解析的 RTSP 地址**重拉一次(跳过发现与取流,方便反复对比)。 + Future updateTuning( + RtspPlaybackTuning tuning, { + bool reconnect = true, + }) async { + // 先比一下码流选择有没有变:它决定取哪一路 Profile,不能靠“同一个 URL 重拉”体现。 + final bool profileChanged = tuning.profileSelect != _tuning.profileSelect; + _tuning = tuning; + await tuning.save(); + emit(state.copyWith(tuning: tuning)); + await tuning.apply(); + debugPrint('🛠 [调参] 已保存并下发:${tuning.summary}'); + if (!reconnect) return; + // 换码流 = 换一路 URL,必须重新走 ONVIF 取流;其余参数只影响播放器本身, + // 用已解析的地址直接重拉更快(避开发现/取流的十秒级耗时)。 + final OnvifCamera? cam = state.selected; + if (profileChanged && cam != null) { + await selectCamera(cam); + return; + } + await reapplyAndReplay(); + } + + /// 恢复默认(Media3 原生行为),并重拉一次以排除自己改坏的参数。 + Future resetTuning() async { + await RtspPlaybackTuning.defaults.clearSaved(); + await updateTuning(RtspPlaybackTuning.defaults); + } + + /// 用当前 rtspUrl 重新拉流(不走发现/取流流程)。没有画面时只存不重连。 + Future reapplyAndReplay() async { + final url = state.rtspUrl; + if (url == null) return; + if (state.phase != LocalCameraPhase.playing && + state.phase != LocalCameraPhase.disconnected && + state.phase != LocalCameraPhase.playFailed) { + return; + } + _userStopped = false; + emit( + state.copyWith( + phase: LocalCameraPhase.connecting, + playStep: ChainStatus.running, + clearMessage: true, + showReconnect: false, + ), + ); + await _disposeController(); + _armOpenWatchdog(); + final ok = await _tryPlay(url, timeout: const Duration(seconds: 8)); + _cancelWatchdog(); + if (ok) { + emit( + state.copyWith( + phase: LocalCameraPhase.playing, + playStep: ChainStatus.success, + clearMessage: true, + showReconnect: false, + ), + ); + } else { + emit( + state.copyWith( + phase: LocalCameraPhase.playFailed, + playStep: ChainStatus.failed, + message: '重拉失败:当前参数下未能起播', + showReconnect: true, + ), + ); + _scheduleRecover(const Duration(seconds: 5)); + } + } + /// 返回摄像头列表(重新搜索)。 Future backToList() async { _userStopped = true; @@ -630,11 +878,14 @@ class LocalCameraCubit extends Cubit { @override Future close() async { _userStopped = true; + WidgetsBinding.instance.removeObserver(this); _openWatchdog?.cancel(); _stallTimer?.cancel(); + _recoverTimer?.cancel(); _lagTimer?.cancel(); _connectivitySub?.cancel(); await _disposeController(); + _probe.dispose(); 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 index 9c3eaef6..a630852d 100644 --- a/lib/features/v2/local_camera/presentation/cubit/local_camera_state.dart +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import '../../data/rtsp_playback_tuning.dart'; import '../../domain/entities/onvif_camera.dart'; /// 链条每一步的状态 @@ -29,6 +30,7 @@ class LocalCameraState extends Equatable { final String? rtspUrl; final String? message; // 提示语 final bool showReconnect; // 是否显示"重新连接"按钮 + final RtspPlaybackTuning tuning; // 直播调参(传输方式 / 缓冲水位),设置面板读写 const LocalCameraState({ this.phase = LocalCameraPhase.idle, @@ -41,6 +43,7 @@ class LocalCameraState extends Equatable { this.rtspUrl, this.message, this.showReconnect = false, + this.tuning = RtspPlaybackTuning.defaults, }); bool get isPlaying => phase == LocalCameraPhase.playing; @@ -65,6 +68,7 @@ class LocalCameraState extends Equatable { String? message, bool clearMessage = false, bool? showReconnect, + RtspPlaybackTuning? tuning, }) { return LocalCameraState( phase: phase ?? this.phase, @@ -77,6 +81,7 @@ class LocalCameraState extends Equatable { rtspUrl: clearRtsp ? null : (rtspUrl ?? this.rtspUrl), message: clearMessage ? null : (message ?? this.message), showReconnect: showReconnect ?? this.showReconnect, + tuning: tuning ?? this.tuning, ); } @@ -92,5 +97,6 @@ class LocalCameraState extends Equatable { rtspUrl, message, showReconnect, + tuning, ]; } 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 index 983158c3..c4d26f34 100644 --- a/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart +++ b/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:video_player/video_player.dart'; +import 'package:maibu_satabot_v2/features/pv_control/presentation/pages/pv_control_page.dart'; + import '../../domain/entities/onvif_camera.dart'; import '../cubit/local_camera_cubit.dart'; import '../cubit/local_camera_state.dart'; +import '../widgets/rtsp_tuning_panel.dart'; /// 本地视频页:ONVIF 自动发现 + RTSP 播放(搜索与播放合一)。 /// 上方为播放器 / 搜索状态 / 摄像头列表,下方为链路状态"链条"实时展示。 @@ -37,7 +40,8 @@ class _LocalCameraViewState extends State<_LocalCameraView> { return BlocBuilder( builder: (context, state) { return PopScope( - canPop: state.phase != LocalCameraPhase.playing && + canPop: + state.phase != LocalCameraPhase.playing && state.phase != LocalCameraPhase.connecting, onPopInvokedWithResult: (didPop, result) { if (!didPop) { @@ -61,7 +65,10 @@ class _LocalCameraViewState extends State<_LocalCameraView> { ), title: const Text( '本地视频', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), centerTitle: true, actions: [ @@ -71,12 +78,34 @@ class _LocalCameraViewState extends State<_LocalCameraView> { icon: const Icon(Icons.refresh, color: Colors.white), onPressed: () => cubit.runDiscovery(), ), + // 🔥 入口:进入光伏电站机器人远程控制(横屏动态大屏)页面 + IconButton( + tooltip: '远程控制', + icon: const Icon( + Icons.sports_esports_outlined, + color: Colors.white, + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PvControlPage(deviceId: ''), + ), + ); + }, + ), ], ), body: SafeArea( child: Column( children: [ Expanded(child: _buildContent(context, cubit, state)), + // 链路探针:只读结论,放在调参条上方(同样不遮住画面)。 + RtspLinkProbeBar(probe: cubit.probe), + // 调参条:固定在链路条上方(不放画面上),改完直接重拉流做 A/B。 + RtspTuningBar( + tuning: state.tuning, + onOpen: () => showRtspTuningSheet(context, cubit), + ), _buildChain(context, state), ], ), @@ -128,9 +157,7 @@ class _LocalCameraViewState extends State<_LocalCameraView> { ) { return Stack( children: [ - Positioned.fill( - child: _buildPlayer(cubit), - ), + Positioned.fill(child: _buildPlayer(cubit)), // 连接中遮罩 if (state.phase == LocalCameraPhase.connecting) Positioned.fill( @@ -171,10 +198,7 @@ class _LocalCameraViewState extends State<_LocalCameraView> { ? size.width / size.height : 16 / 9; return Center( - child: AspectRatio( - aspectRatio: ratio, - child: VideoPlayer(controller), - ), + child: AspectRatio(aspectRatio: ratio, child: VideoPlayer(controller)), ); } @@ -229,45 +253,49 @@ class _LocalCameraViewState extends State<_LocalCameraView> { 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, + // FittedBox:嵌入受限高度容器时整组等比缩小,避免「重试」按钮溢出 + child: FittedBox( + fit: BoxFit.scaleDown, + 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, ), - ) - 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, + 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), ), - onPressed: onAction, - icon: const Icon(Icons.refresh, size: 18), - label: Text(actionLabel), - ), + ], ], - ], + ), ), ), ); @@ -346,7 +374,10 @@ class _ChainNode extends StatelessWidget { child: loading ? Padding( padding: const EdgeInsets.all(3), - child: CircularProgressIndicator(color: color, strokeWidth: 3), + child: CircularProgressIndicator( + color: color, + strokeWidth: 3, + ), ) : CircleAvatar( backgroundColor: color, @@ -360,7 +391,9 @@ class _ChainNode extends StatelessWidget { Text( step.label, style: TextStyle( - color: step.status == ChainStatus.idle ? Colors.white38 : Colors.white, + color: step.status == ChainStatus.idle + ? Colors.white38 + : Colors.white, fontSize: 12, ), ), @@ -435,7 +468,10 @@ class _CameraTile extends StatelessWidget { : (camera.manufacturer.isNotEmpty ? camera.manufacturer : '未知型号'), - style: const TextStyle(color: Colors.white54, fontSize: 13), + style: const TextStyle( + color: Colors.white54, + fontSize: 13, + ), ), ], ), diff --git a/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_fields.dart b/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_fields.dart new file mode 100644 index 00000000..99341016 --- /dev/null +++ b/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_fields.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; + +import '../../data/rtsp_playback_tuning.dart'; + +/// 调参弹窗用的公共小组件(标题 / 预设档位 / 传输方式 / 毫秒输入框)。 + +class TuningSectionTitle extends StatelessWidget { + const TuningSectionTitle({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text, + style: const TextStyle( + color: Color(0xFFEDEDED), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class TuningHint extends StatelessWidget { + const TuningHint({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + text, + // 不用 white38:在 #141414 底上对比度只有 2.3:1,太阳底下根本看不清。 + style: const TextStyle(color: Color(0xFF9AA0A6), fontSize: 11, height: 1.5), + ), + ); + } +} + +/// 预设档位:现场试验直接点一个再微调,省得对着数字猜。 +class TuningPresetRow extends StatelessWidget { + const TuningPresetRow({super.key, required this.draft, required this.onPick}); + + final RtspPlaybackTuning draft; + final ValueChanged onPick; + + static const List<(String, RtspPlaybackTuning)> presets = <(String, RtspPlaybackTuning)>[ + ('原生默认', RtspPlaybackTuning.defaults), + ('均衡', RtspPlaybackTuning.balanced), + ('抗花屏', RtspPlaybackTuning.antiArtifact), + ('低延迟', RtspPlaybackTuning.lowLatency), + ]; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in presets) + _TuningPresetPill( + label: p.$1, + selected: p.$2 == draft, + onTap: () => onPick(p.$2), + ), + ], + ); + } +} + +/// 预设档位药丸。 +/// +/// 之前用的是 ChoiceChip:弹窗底色是写死的深色,而 APP 可能是浅色主题, +/// chip 会拿到「浅色底 + 白色字」这种看不清的组合,且和下面的自绘卡片风格不统一。 +/// 自己画可以彻底不依赖主题,也不随用户的浅色/深色设置漂移。 +class _TuningPresetPill extends StatelessWidget { + const _TuningPresetPill({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: selected ? const Color(0xFF165DFF) : const Color(0xFF2A2A2A), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? const Color(0xFF5B8DEF) : const Color(0xFF4A4A4A), + ), + ), + child: Text( + label, + style: TextStyle( + color: selected ? Colors.white : const Color(0xFFDDDDDD), + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + } +} + +/// UDP / TCP 二选一。用两个可点卡片而不是 Switch,是为了把各自的代价直接写在脸上。 +class TuningTransportPicker extends StatelessWidget { + const TuningTransportPicker({super.key, required this.forceTcp, required this.onChanged}); + + final bool forceTcp; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _TuningTransportOption( + title: 'UDP', + subtitle: '延迟低 / 丢包即花屏', + selected: !forceTcp, + onTap: () => onChanged(false), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TuningTransportOption( + title: 'TCP', + subtitle: '画面完整 / 可能卡顿', + selected: forceTcp, + onTap: () => onChanged(true), + ), + ), + ], + ); + } +} + +class _TuningTransportOption extends StatelessWidget { + const _TuningTransportOption({ + required this.title, + required this.subtitle, + required this.selected, + required this.onTap, + }); + + final String title; + final String subtitle; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: selected + ? const Color(0xFF165DFF).withValues(alpha: 0.30) + : const Color(0xFF232323), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? const Color(0xFF5B8DEF) + : const Color(0xFF3D3D3D), + width: selected ? 1.4 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: selected ? Colors.white : const Color(0xFFCCCCCC), + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(color: Color(0xFF9AA0A6), fontSize: 11), + ), + ], + ), + ), + ); + } +} + +/// 毫秒参数输入框:右侧常驻一句“这个值到底影响什么”,避免瞎填。 +class TuningMsField extends StatefulWidget { + const TuningMsField({ + super.key, + required this.label, + required this.value, + required this.hint, + required this.onChanged, + this.suffix = 'ms', + }); + + final String label; + final int value; + final String hint; + final ValueChanged onChanged; + + /// 单位后缀。水位类是 ms,缓冲字节类是 KB。 + final String suffix; + + @override + State createState() => _TuningMsFieldState(); +} + +class _TuningMsFieldState extends State { + late final TextEditingController _ctrl = + TextEditingController(text: '${widget.value}'); + + @override + void didUpdateWidget(covariant TuningMsField oldWidget) { + super.didUpdateWidget(oldWidget); + // 点预设档位时要同步回填输入框;光标还在框里(用户正在输入)时不覆盖。 + if (widget.value != oldWidget.value && !_ctrl.selection.isValid) { + _ctrl.text = '${widget.value}'; + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: const TextStyle(color: Color(0xFFCCCCCC), fontSize: 12), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: TextField( + controller: _ctrl, + keyboardType: TextInputType.number, + // 浅色主题下默认 cursor 是深色,在深底输入框里几乎看不见。 + cursorColor: const Color(0xFF94BFFF), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + isDense: true, + suffixText: widget.suffix, + suffixStyle: const TextStyle( + color: Color(0xFF9AA0A6), + fontSize: 11, + ), + filled: true, + fillColor: const Color(0xFF232323), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF3D3D3D)), + ), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF5B8DEF)), + ), + ), + onChanged: (v) => widget.onChanged(int.tryParse(v.trim()) ?? 0), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + widget.hint, + style: const TextStyle( + color: Color(0xFF9AA0A6), + fontSize: 11, + height: 1.4, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// 多选一卡片行:把每个选项的代价直接写在卡片上,比下拉框更适合现场试参数。 +class TuningChoiceRow extends StatelessWidget { + const TuningChoiceRow({ + super.key, + required this.options, + required this.labelOf, + required this.selected, + required this.onChanged, + this.subtitleOf, + }); + + final List options; + final String Function(T) labelOf; + final String Function(T)? subtitleOf; + final T selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (int i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: _TuningTransportOption( + title: labelOf(options[i]), + subtitle: subtitleOf?.call(options[i]) ?? '', + selected: options[i] == selected, + onTap: () => onChanged(options[i]), + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_panel.dart b/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_panel.dart new file mode 100644 index 00000000..710623bc --- /dev/null +++ b/lib/features/v2/local_camera/presentation/widgets/rtsp_tuning_panel.dart @@ -0,0 +1,532 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/rtsp_link_probe.dart'; +import '../../data/rtsp_playback_tuning.dart'; +import '../cubit/local_camera_cubit.dart'; +import 'rtsp_tuning_fields.dart'; + +/// 链路条上方的「调参条」:一行显示当前生效策略,点开弹窗慢慢改。 +/// +/// 放这里而不是画面上:画面区域要留给视频本身,且改完参数会立刻重拉流, +/// 任何浮层都会干扰肉眼判断花屏/延迟的变化。 +class RtspTuningBar extends StatelessWidget { + const RtspTuningBar({ + super.key, + required this.tuning, + required this.onOpen, + }); + + final RtspPlaybackTuning tuning; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF0A0A0A), + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + const Icon(Icons.tune, color: Color(0xFF165DFF), size: 18), + const SizedBox(width: 8), + const Text('调参', style: TextStyle(color: Color(0xFFCCCCCC), fontSize: 12)), + const SizedBox(width: 10), + Expanded( + child: Text( + tuning.summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tuning.isDefault + ? const Color(0xFF9AA0A6) + : const Color(0xFFFFC53D), + fontSize: 12, + ), + ), + ), + if (!tuning.isDefault) + const Padding( + padding: EdgeInsets.only(right: 6), + child: Text( + '已自定义', + style: TextStyle(color: Color(0xFFFFC53D), fontSize: 11), + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF8A8A8A), size: 18), + ], + ), + ), + ), + ); + } +} + +/// 链路探针条:固定在三块信息区最上方,只读不改任何行为。 +/// +/// 它回答的是“图传送过来的数据到底缺不缺”: +/// 进给率明显低于实时 = 数据没到齐(链路吐不上/丢包,病在图传段); +/// 进给完整但画面仍花 = 数据都收到了还花,不是“不够”而是“内容坏了”。 +class RtspLinkProbeBar extends StatelessWidget { + const RtspLinkProbeBar({super.key, required this.probe}); + + final ValueListenable probe; + + static Color _colorOf(LinkVerdict v) { + switch (v) { + case LinkVerdict.healthy: + return const Color(0xFF3DDC84); + case LinkVerdict.lightLoss: + return const Color(0xFFFFC53D); + case LinkVerdict.starved: + return const Color(0xFFFF6B4A); + case LinkVerdict.decoderStalled: + return const Color(0xFFB388FF); + case LinkVerdict.backlogGrowing: + return const Color(0xFFFFB74D); + case LinkVerdict.unknown: + return const Color(0xFF8A8A8A); + } + } + + static IconData _iconOf(LinkVerdict v) { + switch (v) { + case LinkVerdict.healthy: + return Icons.signal_cellular_alt; + case LinkVerdict.lightLoss: + case LinkVerdict.starved: + return Icons.network_check; + case LinkVerdict.decoderStalled: + return Icons.memory; + case LinkVerdict.backlogGrowing: + return Icons.hourglass_top; + case LinkVerdict.unknown: + return Icons.sensors_off; + } + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: probe, + builder: (context, snap, _) { + final LinkVerdict v = snap?.verdict ?? LinkVerdict.unknown; + final Color accent = _colorOf(v); + return Material( + color: const Color(0xFF0A0A0A), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_iconOf(v), color: accent, size: 16), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + snap == null + ? '链路探针:采样中(进画面后 2~4 秒出结果)' + : '${v.label} · ${snap.detail}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + snap == null + ? '靠 position/buffered 的增量判断进给完整度,不需重新编译' + : v.advice, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF9AA0A6), + fontSize: 10.5, + height: 1.35, + ), + ), + ], + ), + ), + if (snap != null && snap.estLossPct > 0) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + '丢帧≈${snap.estLossPct}%', + style: TextStyle(color: accent, fontSize: 11), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +/// 弹出调参面板。关闭时 Cubit 已完成「保存 + 下发(+ 视按钮重拉流)」。 +/// +/// 必须把 Cubit 从调用方的上下文传进来:showModalBottomSheet 走的是 +/// Navigator 里的 Overlay,它在页面 BlocProvider 的**上层**,弹窗内部 +/// context 原本看不到 LocalCameraCubit,会抛 ProviderNotFoundException。 +/// 这里用 BlocProvider.value 把它重新注入到弹窗子树(不负责 dispose)。 +Future showRtspTuningSheet(BuildContext context, LocalCameraCubit cubit) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF141414), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => BlocProvider.value( + value: cubit, + child: const _RtspTuningSheet(), + ), + ); +} + +class _RtspTuningSheet extends StatefulWidget { + const _RtspTuningSheet(); + + @override + State<_RtspTuningSheet> createState() => _RtspTuningSheetState(); +} + +class _RtspTuningSheetState extends State<_RtspTuningSheet> { + late RtspPlaybackTuning _draft; + String _nativeEcho = ''; + + @override + void initState() { + super.initState(); + _draft = context.read().tuning; + } + + /// 读回原生侧真正生效的值:用来定性「改了到底有没有进插件」。 + Future _readBack() async { + final RtspPlaybackTuning? live = + await context.read().readBackTuning(); + if (!mounted) return; + setState(() { + _nativeEcho = live == null + ? '读回失败:调参通道不存在(确认本地覆盖包已生效 / 是否 Android 端)' + : '原生当前生效:TCP=${live.forceRtpTcp} · ${live.minBufferMs}~' + '${live.maxBufferMs}ms · 起播${live.bufferForPlaybackMs}ms · ' + '超时${live.rtspTimeoutMs == 0 ? '默认' : '${live.rtspTimeoutMs}ms'} · ' + '字节${live.targetBufferBytes == 0 ? '默认' : '${live.targetBufferBytes ~/ 1024}KB'} · ' + '${live.decoderMode.label}'; + }); + } + + void _apply({required bool replay}) { + context.read().updateTuning(_draft, reconnect: replay); + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + messenger.showSnackBar( + SnackBar( + content: Text( + replay ? '已应用并重拉流:${_draft.summary}' : '已应用(下次拉流生效):${_draft.summary}', + ), + duration: const Duration(seconds: 2), + backgroundColor: const Color(0xFF165DFF), + ), + ); + } + + @override + Widget build(BuildContext context) { + final double maxHeight = MediaQuery.sizeOf(context).height * 0.9; + // 弹窗底色是写死的深色,但 APP 可能跑在浅色主题上:不锁主题的话, + // 没显式给色的控件(按钮文字 / chip / 输入框 / 水波纹)会继承浅色主题的默认色, + // 就出现「白底 + 白字」看不清。只钉本弹窗这一棵子树,不影响全局主题。 + return Theme( + data: _darkThemeOf(context), + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _SheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + const Expanded( + child: Text( + '直播调参(花屏 / 延迟)', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton( + onPressed: () => + setState(() => _draft = RtspPlaybackTuning.defaults), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF94BFFF), + ), + child: const Text('恢复默认', style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + Flexible(child: _buildForm()), + _buildActions(), + ], + ), + ), + ), + ); + } + + /// 把弹窗子树锁到深色主题:保留宿主主题的字号/字体族(随系统无障碍缩放), + /// 只换 brightness / colorScheme / textTheme 这三样决定前景色的东西。 + ThemeData _darkThemeOf(BuildContext context) { + final ThemeData base = Theme.of(context); + return base.copyWith( + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF165DFF), + brightness: Brightness.dark, + ), + textTheme: base.textTheme.apply( + bodyColor: const Color(0xFFE8E8E8), + displayColor: Colors.white, + ), + ); + } + + Widget _buildForm() { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + children: [ + const TuningSectionTitle(text: '预设档位(先点一个再微调)'), + TuningPresetRow( + draft: _draft, + onPick: (t) => setState(() => _draft = t), + ), + const SizedBox(height: 16), + const TuningSectionTitle(text: '策略一:传输方式(治花屏)'), + TuningTransportPicker( + forceTcp: _draft.forceRtpTcp, + onChanged: (v) => setState(() => _draft = _draft.copyWith(forceRtpTcp: v)), + ), + const TuningHint( + text: 'UDP 不重传,空口丢包直接解出马赛克;TCP 会重传把画面补全,' + '代价是弱链路下卡顿与延迟上升。', + ), + const SizedBox(height: 16), + const TuningSectionTitle(text: '策略二:缓冲水位(治延迟)'), + TuningMsField( + label: 'minBufferMs(重新加载水位)', + value: _draft.minBufferMs, + hint: 'Media3 有 500ms 硬下限,填更小不生效', + onChanged: (v) => setState(() => _draft = _draft.copyWith(minBufferMs: v)), + ), + TuningMsField( + label: 'maxBufferMs(停止加载水位 = 延迟上限)', + value: _draft.maxBufferMs, + hint: '平均积压约 (min+max)/2;越小越跟手,太小易卡', + onChanged: (v) => setState(() => _draft = _draft.copyWith(maxBufferMs: v)), + ), + TuningMsField( + label: 'bufferForPlaybackMs(起播所需缓冲)', + value: _draft.bufferForPlaybackMs, + hint: '直播建议 0:首帧到达即渲染', + onChanged: (v) => + setState(() => _draft = _draft.copyWith(bufferForPlaybackMs: v)), + ), + TuningMsField( + label: 'bufferForPlaybackAfterRebufferMs(卡完恢复所需缓冲)', + value: _draft.bufferForPlaybackAfterRebufferMs, + hint: '直播建议 0:避免每次卡完再攒一段', + onChanged: (v) => setState( + () => _draft = _draft.copyWith(bufferForPlaybackAfterRebufferMs: v), + ), + ), + TuningMsField( + label: 'rtspTimeoutMs(RTSP 超时,0=用默认)', + value: _draft.rtspTimeoutMs, + hint: '过小会在弱链路上误判「流结束」而断流', + onChanged: (v) => setState(() => _draft = _draft.copyWith(rtspTimeoutMs: v)), + ), + const SizedBox(height: 16), + _buildStreamAndDecoder(), + const SizedBox(height: 8), + _buildSummary(), + TextButton( + onPressed: _readBack, + child: const Text( + '读回原生实际生效值(验证参数有没有真的进插件)', + style: TextStyle(color: Color(0xFF94BFFF), fontSize: 12), + ), + ), + ], + ); + } + + /// 策略三:取哪一路码流 + 由谁解码 + 字节上限。 + /// 这一区才是「播放层」:前面两项只能决定“坏数据怎么补”,这里决定“拿到的是什么数据”。 + Widget _buildStreamAndDecoder() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const TuningSectionTitle(text: '策略三:取流与解码(播放层)'), + TuningChoiceRow( + options: RtspProfileSelect.values, + labelOf: (p) => p.label, + selected: _draft.profileSelect, + onChanged: (p) => + setState(() => _draft = _draft.copyWith(profileSelect: p)), + ), + TuningHint(text: '当前:${_draft.profileSelect.desc}。改这一档会重新走 ONVIF 取流(URL 会变)。'), + const SizedBox(height: 12), + TuningChoiceRow( + options: RtspDecoderMode.values, + labelOf: (m) => m.label, + selected: _draft.decoderMode, + onChanged: (m) => + setState(() => _draft = _draft.copyWith(decoderMode: m)), + ), + const TuningHint( + text: 'Media3 默认硬解,且硬解初始化失败不会自动回退软解;怀疑解码侧问题时,切软解对比同一画面。', + ), + const SizedBox(height: 12), + TuningMsField( + label: 'targetBufferBytes(字节上限,按 KB 填)', + value: _draft.targetBufferBytes ~/ 1024, + suffix: 'KB', + hint: '0 = 用默认(视频约 2MB)。高码率下 2MB 不到 1 秒就撞上限,会频繁 rebuffer,可试 8192', + onChanged: (kb) => setState( + () => _draft = _draft.copyWith(targetBufferBytes: kb < 0 ? 0 : kb * 1024), + ), + ), + ], + ); + } + + Widget _buildSummary() { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF3D3D3D)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '预计稳态积压 ≈ ${_draft.estimatedBacklogMs}ms', + style: const TextStyle(color: Color(0xFF94BFFF), fontSize: 12), + ), + const SizedBox(height: 4), + const Text( + '注意:这只是播放器内部水位,不等于肉眼端到端延迟——后端的大头通常在摄像头 GOP / ' + '图传编码,而不是这里。', + style: TextStyle(color: Color(0xFF9AA0A6), fontSize: 11, height: 1.4), + ), + if (_nativeEcho.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + _nativeEcho, + style: const TextStyle(color: Color(0xFFB8B8B8), fontSize: 11), + ), + ], + ], + ), + ); + } + + // 底部两个按钮必须用同一套尺寸:主题只给 elevatedButtonTheme 设了 + // padding(vertical:16, horizontal:24) 与 radius 12,outlinedButton 没被覆盖, + // 两个按钮默认内间距/圆角完全不同 → 一高一矮,看着就别扭。这里显式对齐。 + static const EdgeInsets _actionPadding = EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, + ); + static const Size _actionMinSize = Size(0, 44); + static const TextStyle _actionText = TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ); + static const OutlinedBorder _actionShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ); + + Widget _buildActions() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _apply(replay: false), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFCCCCCC), + backgroundColor: Colors.transparent, + side: const BorderSide(color: Color(0xFF4A4A4A)), + padding: _actionPadding, + minimumSize: _actionMinSize, + textStyle: _actionText, + shape: _actionShape, + ), + child: const Text('仅保存 · 下次拉流生效'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: () => _apply(replay: true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + elevation: 0, + padding: _actionPadding, + minimumSize: _actionMinSize, + textStyle: _actionText, + shape: _actionShape, + ), + child: const Text('保存 · 立即重拉流'), + ), + ), + ], + ), + ), + ); + } +} + +class _SheetHandle extends StatelessWidget { + const _SheetHandle(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} diff --git a/lib/features/v2/usb_camera/data/usb_camera_prefs.dart b/lib/features/v2/usb_camera/data/usb_camera_prefs.dart new file mode 100644 index 00000000..4d08cfc6 --- /dev/null +++ b/lib/features/v2/usb_camera/data/usb_camera_prefs.dart @@ -0,0 +1,35 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 记录上一次使用的 USB 摄像头,插拔后优先重连。 +class UsbCameraPrefs { + static const String _lastDeviceIdKey = 'usb_camera_last_device_id'; + static const String _lastResolutionKey = 'usb_camera_last_resolution'; + + static Future getLastDeviceId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_lastDeviceIdKey); + } + + static Future setLastDeviceId(String? id) async { + final prefs = await SharedPreferences.getInstance(); + if (id == null) { + await prefs.remove(_lastDeviceIdKey); + return; + } + await prefs.setString(_lastDeviceIdKey, id); + } + + static Future getLastResolution() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_lastResolutionKey); + } + + static Future setLastResolution(String? resolution) async { + final prefs = await SharedPreferences.getInstance(); + if (resolution == null) { + await prefs.remove(_lastResolutionKey); + return; + } + await prefs.setString(_lastResolutionKey, resolution); + } +} diff --git a/lib/features/v2/usb_camera/data/uvc_camera_service.dart b/lib/features/v2/usb_camera/data/uvc_camera_service.dart new file mode 100644 index 00000000..7149b1e5 --- /dev/null +++ b/lib/features/v2/usb_camera/data/uvc_camera_service.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:usb_camera_android/usb_camera_android.dart' as plugin; + +import '../domain/usb_camera_device.dart'; +import 'usb_camera_prefs.dart'; + +/// UVC 摄像头原生能力封装。 +class UvcCameraService { + bool _isOpen = false; + StreamSubscription? _eventSub; + + /// 枚举当前已连接的 UVC 设备。 + Future> enumerateDevices() async { + final list = await plugin.UsbCameraAndroid.getDeviceList(); + return list + .map( + (d) => UsbCameraDevice( + deviceId: d.deviceId, + vendorId: d.vendorId, + productId: d.productId, + productName: d.productName, + ), + ) + .toList(); + } + + /// 请求并打开指定设备。 + Future open( + UsbCameraDevice device, { + String? resolution, + }) async { + await UsbCameraPrefs.setLastDeviceId(device.deviceId); + if (resolution != null) { + await UsbCameraPrefs.setLastResolution(resolution); + } + await plugin.UsbCameraAndroid.open( + device.deviceId, + resolution: resolution, + ); + _isOpen = true; + } + + /// 关闭当前设备。 + Future close() async { + if (!_isOpen) return; + _isOpen = false; + await plugin.UsbCameraAndroid.close(); + } + + /// 切换分辨率。 + Future setResolution(String resolution) async { + await UsbCameraPrefs.setLastResolution(resolution); + await plugin.UsbCameraAndroid.setResolution(resolution); + } + + /// 监听原生端插拔/权限等事件。 + void startListeningEvents( + void Function(String type, String? deviceId) onEvent, + ) { + _eventSub?.cancel(); + _eventSub = plugin.UsbCameraAndroid.eventStream.listen((event) { + final type = event['type'] as String?; + final data = event['data'] as Map?; + final deviceId = data?['deviceId'] as String?; + onEvent(type ?? 'unknown', deviceId); + }); + } + + /// 释放事件监听。 + Future dispose() async { + await _eventSub?.cancel(); + _eventSub = null; + } +} diff --git a/lib/features/v2/usb_camera/domain/usb_camera_device.dart b/lib/features/v2/usb_camera/domain/usb_camera_device.dart new file mode 100644 index 00000000..86fd7903 --- /dev/null +++ b/lib/features/v2/usb_camera/domain/usb_camera_device.dart @@ -0,0 +1,36 @@ +/// USB(UVC)摄像头设备实体。 +class UsbCameraDevice { + const UsbCameraDevice({ + required this.deviceId, + this.vendorId, + this.productId, + this.productName, + this.supportedResolutions = const [], + }); + + /// 系统分配的设备标识(重启可能变化,仅做本次会话使用)。 + final String deviceId; + + final int? vendorId; + final int? productId; + final String? productName; + + /// 支持的分辨率字符串,例如 "1920x1080" / "1280x720"。 + final List supportedResolutions; + + UsbCameraDevice copyWith({ + String? deviceId, + int? vendorId, + int? productId, + String? productName, + List? supportedResolutions, + }) { + return UsbCameraDevice( + deviceId: deviceId ?? this.deviceId, + vendorId: vendorId ?? this.vendorId, + productId: productId ?? this.productId, + productName: productName ?? this.productName, + supportedResolutions: supportedResolutions ?? this.supportedResolutions, + ); + } +} diff --git a/lib/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart b/lib/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart new file mode 100644 index 00000000..83c64dd1 --- /dev/null +++ b/lib/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:usb_camera_android/usb_camera_android.dart' as plugin; + +import '../../data/uvc_camera_service.dart'; +import '../../domain/usb_camera_device.dart'; +import 'usb_camera_state.dart'; + +/// USB 摄像头页面 Cubit:枚举 → 授权 → 打开 → 预览。 +class UsbCameraCubit extends Cubit { + UsbCameraCubit(this._service) : super(const UsbCameraState()) { + _service.startListeningEvents(_onNativeEvent); + } + + final UvcCameraService _service; + + // ---------- 安全 emit:防止 close 后事件循环仍唤起导致 Bad state ---------- + + void _safeEmit(UsbCameraState newState) { + if (isClosed) return; + emit(newState); + } + + // ---------- 原生事件回调(插拔等) ---------- + + void _onNativeEvent(String type, String? deviceId) { + // isClosed 守卫:Cubit 已销毁时忽略,避免 Bad state + if (isClosed) return; + if (type == 'detached') { + _onDeviceDetached(); + } + } + + // ---------- 页面初始化:枚举设备并尝试直接打开 ---------- + + Future start() async { + _safeEmit(state.copyWith(phase: UsbCameraPhase.scanning)); + try { + final devices = await _service.enumerateDevices(); + + if (devices.isEmpty) { + _safeEmit(state.copyWith( + phase: UsbCameraPhase.openFailed, + message: '未检测到 USB 摄像头\n请确认采集卡已插入', + )); + return; + } + + if (devices.length > 1) { + _safeEmit(state.copyWith( + phase: UsbCameraPhase.selecting, + devices: devices, + message: '发现 ${devices.length} 个 USB 摄像头,请选择一个', + )); + return; + } + + await _open(devices.first); + } catch (e) { + debugPrint('❌ [USB Camera] 枚举失败: $e'); + _safeEmit(state.copyWith( + phase: UsbCameraPhase.openFailed, + message: '无法枚举 USB 设备\n$e', + )); + } + } + + /// 多设备列表中用户点击选择某台。 + Future selectAndOpen(UsbCameraDevice device) => _open(device); + + /// 重新连接(重试按钮)。 + Future reconnect() => start(); + + // ---------- 核心:打开设备 ---------- + + Future _open(UsbCameraDevice device) async { + _safeEmit(state.copyWith( + phase: UsbCameraPhase.connecting, + selected: device, + clearMessage: true, + )); + try { + await _service.open(device, resolution: state.resolution); + + double? aspectRatio; + try { + final size = await plugin.UsbCameraAndroid.getPreviewSize(); + if (size != null && size.height > 0) { + aspectRatio = size.width / size.height; + } + } catch (_) {} + + _safeEmit(state.copyWith( + phase: UsbCameraPhase.previewing, + selected: device, + previewAspectRatio: aspectRatio, + )); + } on PlatformException catch (e) { + // CANCELLED:用户在权限对话框未响应就离开了页面,close() 链路主动取消了等待, + // 属于正常退出行为,不需要展示错误提示。 + if (e.code == 'CANCELLED') { + debugPrint('[USB Camera] 打开被取消(用户离开页面),忽略'); + return; + } + debugPrint('❌ [USB Camera] 打开失败: ${e.code} ${e.message}'); + _safeEmit(state.copyWith( + phase: UsbCameraPhase.openFailed, + message: _friendlyOpenError(e), + )); + } catch (e) { + debugPrint('❌ [USB Camera] 打开失败: $e'); + _safeEmit(state.copyWith( + phase: UsbCameraPhase.openFailed, + message: '无法打开 USB 摄像头\n$e', + )); + } + } + + /// 将原生 PlatformException 转成用户友好的提示语。 + String _friendlyOpenError(PlatformException e) { + final msg = e.message ?? ''; + if (msg.contains('拒绝') || msg.toLowerCase().contains('permission')) { + return 'USB 权限被拒绝\n请点击「重试」,在弹窗中选择「允许」'; + } + if (e.code == 'DEVICE_NOT_FOUND') { + return 'USB 设备已拔出\n请重新插入后点击「重试」'; + } + return '无法打开 USB 摄像头\n$msg'; + } + + // ---------- 设备热拔插 ---------- + + void _onDeviceDetached() { + if (state.phase == UsbCameraPhase.previewing || + state.phase == UsbCameraPhase.connecting) { + _safeEmit(state.copyWith( + phase: UsbCameraPhase.disconnected, + message: 'USB 摄像头已断开', + )); + } + } + + // ---------- 生命周期 ---------- + + @override + Future close() async { + // 先取消事件订阅,避免原生回调唤醒已关闭的 Cubit + await _service.dispose(); + // 再关硬件(原生层会取消等待中的 open MethodChannel,但我们已 catch CANCELLED) + await _service.close(); + await super.close(); + } +} diff --git a/lib/features/v2/usb_camera/presentation/cubit/usb_camera_state.dart b/lib/features/v2/usb_camera/presentation/cubit/usb_camera_state.dart new file mode 100644 index 00000000..1d7c30a3 --- /dev/null +++ b/lib/features/v2/usb_camera/presentation/cubit/usb_camera_state.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/usb_camera_device.dart'; + +enum UsbCameraPhase { + idle, + scanning, // 正在枚举 USB 设备 + needPermission, // 等用户授权 + selecting, // 找到多个,让用户选 + connecting, // 已授权,正在打开 + previewing, // 正在出画面 + disconnected, // 设备被拔出 + openFailed, // 打开失败 +} + +class UsbCameraState extends Equatable { + const UsbCameraState({ + this.phase = UsbCameraPhase.idle, + this.devices = const [], + this.selected, + this.message, + this.resolution, + this.previewAspectRatio, + }); + + final UsbCameraPhase phase; + final List devices; + final UsbCameraDevice? selected; + final String? message; + final String? resolution; + final double? previewAspectRatio; + + bool get showPreview => + phase == UsbCameraPhase.previewing || + phase == UsbCameraPhase.disconnected; + + UsbCameraState copyWith({ + UsbCameraPhase? phase, + List? devices, + UsbCameraDevice? selected, + bool clearSelected = false, + String? message, + bool clearMessage = false, + String? resolution, + bool clearResolution = false, + double? previewAspectRatio, + }) { + return UsbCameraState( + phase: phase ?? this.phase, + devices: devices ?? this.devices, + selected: clearSelected ? null : (selected ?? this.selected), + message: clearMessage ? null : (message ?? this.message), + resolution: clearResolution ? null : (resolution ?? this.resolution), + previewAspectRatio: previewAspectRatio ?? this.previewAspectRatio, + ); + } + + @override + List get props => [ + phase, + devices, + selected, + message, + resolution, + previewAspectRatio, + ]; +} diff --git a/lib/features/v2/usb_camera/presentation/pages/usb_camera_page.dart b/lib/features/v2/usb_camera/presentation/pages/usb_camera_page.dart new file mode 100644 index 00000000..243dab63 --- /dev/null +++ b/lib/features/v2/usb_camera/presentation/pages/usb_camera_page.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/uvc_camera_service.dart'; +import '../cubit/usb_camera_cubit.dart'; +import '../cubit/usb_camera_state.dart'; +import '../widgets/usb_camera_preview.dart'; +import '../widgets/usb_camera_status_layer.dart'; + +/// USB 摄像头页:点击入口后直接尝试出画面,不展示额外步骤。 +class UsbCameraPage extends StatelessWidget { + const UsbCameraPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => UsbCameraCubit(UvcCameraService())..start(), + child: const _UsbCameraView(), + ); + } +} + +class _UsbCameraView extends StatelessWidget { + const _UsbCameraView(); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return BlocBuilder( + builder: (context, state) { + return PopScope( + canPop: state.phase != UsbCameraPhase.connecting && + state.phase != UsbCameraPhase.previewing, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) cubit.close(); // 先释放再返回 + }, + child: Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: const Color(0xFF165DFF), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () { + cubit.close(); + Navigator.of(context).pop(); + }, + ), + title: const Text( + 'USB 摄像头', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: state.showPreview && state.selected != null + ? UsbCameraPreview( + device: state.selected!, + phase: state.phase, + onReconnect: cubit.reconnect, + aspectRatio: state.previewAspectRatio, + ) + : UsbCameraStatusLayer(state: state, cubit: cubit), + ), + ); + }, + ); + } +} diff --git a/lib/features/v2/usb_camera/presentation/widgets/usb_camera_preview.dart b/lib/features/v2/usb_camera/presentation/widgets/usb_camera_preview.dart new file mode 100644 index 00000000..baa8cf7a --- /dev/null +++ b/lib/features/v2/usb_camera/presentation/widgets/usb_camera_preview.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:usb_camera_android/usb_camera_android.dart'; + +import '../../domain/usb_camera_device.dart'; +import '../cubit/usb_camera_state.dart'; + +/// USB 摄像头预览区。 +class UsbCameraPreview extends StatelessWidget { + const UsbCameraPreview({ + super.key, + required this.device, + required this.phase, + required this.onReconnect, + this.aspectRatio, + }); + + final UsbCameraDevice device; + final UsbCameraPhase phase; + final VoidCallback onReconnect; + final double? aspectRatio; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 视频画面:保持宽高比居中,上下/左右填黑边 + Container( + color: Colors.black, + child: Center( + child: aspectRatio != null + ? AspectRatio( + aspectRatio: aspectRatio!, + child: const UsbCameraPreviewView(), + ) + : const UsbCameraPreviewView(), + ), + ), + + // 设备名浮层 + Positioned( + left: 16, + top: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + device.productName ?? 'USB 摄像头', + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + ), + + // 断开遮罩 + if (phase == UsbCameraPhase.disconnected) + Container( + color: Colors.black.withValues(alpha: 0.65), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.usb_off, color: Colors.white54, size: 52), + const SizedBox(height: 18), + const Text( + 'USB 摄像头已断开', + style: TextStyle(color: Colors.white, fontSize: 15), + ), + const SizedBox(height: 22), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + ), + onPressed: onReconnect, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重新连接'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/v2/usb_camera/presentation/widgets/usb_camera_status_layer.dart b/lib/features/v2/usb_camera/presentation/widgets/usb_camera_status_layer.dart new file mode 100644 index 00000000..fb7ed1f4 --- /dev/null +++ b/lib/features/v2/usb_camera/presentation/widgets/usb_camera_status_layer.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import '../../domain/usb_camera_device.dart'; +import '../cubit/usb_camera_cubit.dart'; +import '../cubit/usb_camera_state.dart'; + +/// 未出画面时的状态层:扫描中 / 无设备 / 多设备选择 / 失败。 +class UsbCameraStatusLayer extends StatelessWidget { + const UsbCameraStatusLayer({ + super.key, + required this.state, + required this.cubit, + }); + + final UsbCameraState state; + final UsbCameraCubit cubit; + + @override + Widget build(BuildContext context) { + switch (state.phase) { + case UsbCameraPhase.scanning: + case UsbCameraPhase.connecting: + return _center( + icon: Icons.usb, + spinning: true, + message: state.phase == UsbCameraPhase.scanning + ? '正在检测 USB 摄像头…' + : '正在打开 USB 摄像头…', + ); + case UsbCameraPhase.selecting: + return _deviceList(state.devices); + case UsbCameraPhase.openFailed: + return _center( + icon: Icons.usb_off, + message: state.message ?? 'USB 摄像头打开失败', + actionLabel: '重试', + onAction: cubit.reconnect, + ); + case UsbCameraPhase.needPermission: + case UsbCameraPhase.idle: + case UsbCameraPhase.previewing: + case UsbCameraPhase.disconnected: + return _center(icon: Icons.usb, message: state.message ?? '准备就绪'); + } + } + + Widget _center({ + required IconData icon, + required String message, + bool spinning = false, + String? actionLabel, + VoidCallback? onAction, + }) { + return Container( + color: Colors.black, + alignment: Alignment.center, + // FittedBox:嵌进受限高度的小窗(如 pv 控制页视频区)时整组等比缩小, + // 避免「重试」按钮溢出;全屏独立页空间充足时保持原样。 + child: FittedBox( + fit: BoxFit.scaleDown, + 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), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _deviceList(List devices) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text( + '发现多个 USB 摄像头,请点击选择', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: devices.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, i) { + final d = devices[i]; + return Material( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => cubit.selectAndOpen(d), + 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.usb, + color: Color(0xFF165DFF), + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + d.productName ?? d.deviceId, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon(Icons.chevron_right, color: Colors.white38), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/packages/usb_camera_android/android/build.gradle.kts b/packages/usb_camera_android/android/build.gradle.kts new file mode 100644 index 00000000..b13f6d96 --- /dev/null +++ b/packages/usb_camera_android/android/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +group = "com.maibu.usb_camera_android" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.3.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.13.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("kotlin-android") +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget(JavaVersion.VERSION_17.toString()) + } +} + +android { + namespace = "com.maibu.usb_camera_android" + compileSdk = flutter.compileSdkVersion + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + checkAllWarnings = true + warningsAsErrors = true + disable.addAll(setOf("AndroidGradlePluginVersion", "InvalidPackage", "GradleDependency", "NewerVersionAvailable")) + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + dependencies { + implementation("com.herohan:UVCAndroid:1.0.11") + } +} diff --git a/packages/usb_camera_android/android/settings.gradle.kts b/packages/usb_camera_android/android/settings.gradle.kts new file mode 100644 index 00000000..28590ff9 --- /dev/null +++ b/packages/usb_camera_android/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "usb_camera_android" diff --git a/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraAndroidPlugin.java b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraAndroidPlugin.java new file mode 100644 index 00000000..5a7c2894 --- /dev/null +++ b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraAndroidPlugin.java @@ -0,0 +1,83 @@ +package com.maibu.usb_camera_android; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.platform.PlatformViewRegistry; + +public class UsbCameraAndroidPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler { + private static final String METHOD_CHANNEL = "com.maibu.usb_camera_android/methods"; + private static final String EVENT_CHANNEL = "com.maibu.usb_camera_android/events"; + private static final String VIEW_TYPE = "com.maibu.usb_camera_android/preview"; + + private MethodChannel methodChannel; + private EventChannel eventChannel; + private UsbCameraEngine engine; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + final Context context = binding.getApplicationContext(); + final BinaryMessenger messenger = binding.getBinaryMessenger(); + + engine = new UsbCameraEngine(context); + methodChannel = new MethodChannel(messenger, METHOD_CHANNEL); + methodChannel.setMethodCallHandler(this); + + eventChannel = new EventChannel(messenger, EVENT_CHANNEL); + eventChannel.setStreamHandler(engine); + + final PlatformViewRegistry registry = binding.getPlatformViewRegistry(); + registry.registerViewFactory(VIEW_TYPE, new UsbCameraViewFactory(engine)); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "getDeviceList": + result.success(engine.getDeviceList()); + break; + case "getSupportedResolutions": + result.success(engine.getSupportedResolutions()); + break; + case "getPreviewSize": + result.success(engine.getPreviewSize()); + break; + case "open": + final String deviceId = call.argument("deviceId"); + final String resolution = call.argument("resolution"); + engine.open(deviceId, resolution, result); + break; + case "close": + engine.close(result); + break; + case "setResolution": + final String res = call.argument("resolution"); + engine.setResolution(res, result); + break; + default: + result.notImplemented(); + } + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (methodChannel != null) { + methodChannel.setMethodCallHandler(null); + methodChannel = null; + } + if (eventChannel != null) { + eventChannel.setStreamHandler(null); + eventChannel = null; + } + if (engine != null) { + engine.dispose(); + engine = null; + } + } +} diff --git a/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraEngine.java b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraEngine.java new file mode 100644 index 00000000..d7de9894 --- /dev/null +++ b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraEngine.java @@ -0,0 +1,395 @@ +package com.maibu.usb_camera_android; + +import android.content.Context; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Surface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.herohan.uvcapp.CameraHelper; +import com.herohan.uvcapp.ICameraHelper; +import com.serenegiant.usb.Size; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel; + +/** + * UVC 摄像头引擎:封装 CameraHelper,管理设备状态和预览。 + * + * 权限流程: + * 1. open() 先查 hasPermission() → 有则直接 selectDevice() + * 2. 无权限 → 通过静态 UsbPermissionReceiver 申请(Android 14+ 兼容) + * 3. 用户点"允许"→ Receiver 回调 → selectDevice() + * 用户点"拒绝"→ Receiver 回调 → completeOpen(拒绝) + * 4. 若异常(PendingIntent 创建失败等),立即 completeOpen 避免 Flutter + * MethodChannel 双重回复崩溃。 + * + * 为什么必须用静态 Receiver(详见 UsbPermissionReceiver 类注释): + * Android 14+ 禁止 FLAG_MUTABLE + 隐式 Intent,动态 BroadcastReceiver + * 无法构造显式 Intent → 只能用 manifest 静态注册。 + */ +public class UsbCameraEngine implements EventChannel.StreamHandler { + private static final String TAG = "UsbCameraEngine"; + + private final Context appContext; + private final UsbManager usbManager; + private final Handler mainHandler; + private final ICameraHelper cameraHelper; + + private Surface currentSurface; + private boolean isPreviewing = false; + private UsbDevice currentDevice; + private List supportedSizes = new ArrayList<>(); + + private EventChannel.EventSink eventSink; + private MethodChannel.Result pendingOpenResult; + + public UsbCameraEngine(@NonNull Context context) { + this.appContext = context.getApplicationContext(); + this.usbManager = (UsbManager) appContext.getSystemService(Context.USB_SERVICE); + this.mainHandler = new Handler(Looper.getMainLooper()); + this.cameraHelper = new CameraHelper(); + this.cameraHelper.setStateCallback(stateCallback); + } + + // ---------- 设备枚举 ---------- + + public List> getDeviceList() { + List> result = new ArrayList<>(); + for (UsbDevice device : usbManager.getDeviceList().values()) { + if (isUvcDevice(device)) { + result.add(deviceToMap(device)); + } + } + return result; + } + + /** + * 判断是否 UVC 设备。 + * 1. 接口级 class==14 (USB Video Class) + * 2. 设备级 class==239/0xEF(复合设备 Miscellaneous,UVC采集卡常用) + */ + private boolean isUvcDevice(@NonNull UsbDevice device) { + if (device.getDeviceClass() == 239 + && device.getDeviceSubclass() == 2 + && device.getDeviceProtocol() == 1) { + return true; + } + for (int i = 0; i < device.getInterfaceCount(); i++) { + if (device.getInterface(i).getInterfaceClass() == 14) { + return true; + } + } + return false; + } + + private Map deviceToMap(@NonNull UsbDevice device) { + Map map = new HashMap<>(); + map.put("deviceId", String.valueOf(device.getDeviceId())); + map.put("vendorId", device.getVendorId()); + map.put("productId", device.getProductId()); + map.put("productName", device.getProductName()); + map.put("hasPermission", usbManager.hasPermission(device) ? 1 : 0); + return map; + } + + // ---------- 打开 / 关闭 ---------- + + /** + * 打开指定设备: + * 1. 找设备 + * 2. hasPermission() → selectDevice / requestUsbPermission + * 3. try-catch 保护:PendingIntent 异常时立即报错给 Dart,避免 result 残留 + * 导致后续 close() 二次回复崩溃 "Reply already submitted"。 + */ + public void open( + @NonNull String deviceId, + @Nullable String resolution, + @NonNull MethodChannel.Result result) { + UsbDevice target = null; + for (UsbDevice device : usbManager.getDeviceList().values()) { + if (String.valueOf(device.getDeviceId()).equals(deviceId)) { + target = device; + break; + } + } + if (target == null) { + result.error("DEVICE_NOT_FOUND", "未找到指定 USB 设备: " + deviceId, null); + return; + } + + cancelPendingOpen(); + pendingOpenResult = result; + currentDevice = target; + + try { + if (usbManager.hasPermission(target)) { + Log.d(TAG, "已有 USB 权限,直接 selectDevice"); + cameraHelper.selectDevice(target); + } else { + Log.d(TAG, "无 USB 权限,通过 UsbPermissionReceiver 申请: " + deviceId); + requestUsbPermission(target); + } + } catch (Throwable e) { + // 例如 Android 14+ PendingIntent 创建异常、UsbManager 服务不可用等 + Log.e(TAG, "requestPermission 异常: " + e.getMessage(), e); + completeOpen("申请 USB 权限失败: " + e.getMessage()); + } + } + + /** + * 通过静态 UsbPermissionReceiver + 显式 PendingIntent 申请 USB 权限。 + * + * 关键点: + * - 显式 Intent(new Intent(context, UsbPermissionReceiver.class)) + * 使 FLAG_MUTABLE 在 Android 14+ 合法。 + * - FLAG_MUTABLE 允许系统注入 EXTRA_DEVICE / EXTRA_PERMISSION_GRANTED。 + * - 一次性静态回调 sCallback 在 Receiver 内部触发后立即清空。 + */ + private void requestUsbPermission(@NonNull UsbDevice device) { + UsbPermissionReceiver.sCallback = (recvDevice, granted) -> mainHandler.post(() -> { + if (granted) { + Log.d(TAG, "用户已授予 USB 权限 → selectDevice"); + cameraHelper.selectDevice(recvDevice); + } else { + Log.d(TAG, "用户拒绝了 USB 权限"); + completeOpen("用户拒绝了 USB 权限,请点击「重试」并在弹窗中选择「允许」"); + postEvent("cancelled", deviceToMap(recvDevice)); + } + }); + + android.app.PendingIntent pi = + UsbPermissionReceiver.createPermissionIntent(appContext, device); + usbManager.requestPermission(device, pi); + } + + public void close(@NonNull MethodChannel.Result result) { + cancelPendingOpen(); + UsbPermissionReceiver.sCallback = null; // 取消等待中的权限回调 + if (cameraHelper.isCameraOpened()) { + cameraHelper.closeCamera(); + } + currentDevice = null; + isPreviewing = false; + if (currentSurface != null) { + cameraHelper.removeSurface(currentSurface); + } + result.success(null); + } + + /** + * 取消等待中的 open MethodChannel 回调。 + * 用 try-catch 保护:如果 Flutter 框架已经回复过(例如异常时自动 error), + * 重复调用 result.error() 会抛 IllegalStateException("Reply already submitted") + * 并导致进程崩溃。忽略即可。 + */ + private void cancelPendingOpen() { + if (pendingOpenResult != null) { + final MethodChannel.Result result = pendingOpenResult; + pendingOpenResult = null; + mainHandler.post(() -> { + try { + result.error("CANCELLED", "操作被取消", null); + } catch (IllegalStateException ignored) { + // 框架已回复,重复调用无效,忽略 + } + }); + } + } + + // ---------- 分辨率 ---------- + + /** + * 获取当前预览尺寸(width x height),用于 Flutter 侧计算 AspectRatio。 + * 摄像头未打开时返回 null。 + */ + @Nullable + public Map getPreviewSize() { + if (!isPreviewing) return null; + Size size = cameraHelper.getPreviewSize(); + if (size == null) return null; + Map map = new HashMap<>(); + map.put("width", size.width); + map.put("height", size.height); + return map; + } + + public List getSupportedResolutions() { + List list = new ArrayList<>(); + for (Size size : supportedSizes) { + list.add(size.width + "x" + size.height); + } + return list; + } + + public void setResolution(@NonNull String resolution, @NonNull MethodChannel.Result result) { + Size target = findResolution(resolution); + if (target == null) { + result.error("INVALID_RESOLUTION", "无法解析分辨率: " + resolution, null); + return; + } + if (isPreviewing) { + cameraHelper.stopPreview(); + cameraHelper.setPreviewSize(target); + cameraHelper.startPreview(); + if (currentSurface != null && currentSurface.isValid()) { + cameraHelper.addSurface(currentSurface, false); + } + } + result.success(null); + } + + @Nullable + private Size findResolution(@NonNull String resolution) { + String[] parts = resolution.split("x"); + if (parts.length != 2) return null; + try { + int width = Integer.parseInt(parts[0].trim()); + int height = Integer.parseInt(parts[1].trim()); + for (Size size : supportedSizes) { + if (size.width == width && size.height == height) { + return size; + } + } + } catch (NumberFormatException ignored) { + } + return null; + } + + // ---------- Surface 管理 ---------- + + public void addSurface(@NonNull Surface surface) { + currentSurface = surface; + if (isPreviewing && surface.isValid()) { + cameraHelper.addSurface(surface, false); + } + } + + public void removeSurface(@NonNull Surface surface) { + if (currentSurface == surface) { + if (isPreviewing) { + cameraHelper.removeSurface(surface); + } + currentSurface = null; + } + } + + // ---------- 生命周期 ---------- + + public void dispose() { + UsbPermissionReceiver.sCallback = null; + cameraHelper.release(); + } + + // ---------- CameraHelper 状态回调 ---------- + + private final ICameraHelper.StateCallback stateCallback = new ICameraHelper.StateCallback() { + @Override + public void onAttach(@NonNull UsbDevice device) { + postEvent("attached", deviceToMap(device)); + } + + @Override + public void onDeviceOpen(@NonNull UsbDevice device, boolean isFirstOpen) { + cameraHelper.openCamera(); + } + + @Override + public void onCameraOpen(@NonNull UsbDevice device) { + supportedSizes = cameraHelper.getSupportedSizeList(); + startPreview(); + completeOpen(null); + postEvent("opened", deviceToMap(device)); + } + + @Override + public void onCameraClose(@NonNull UsbDevice device) { + isPreviewing = false; + if (currentSurface != null) { + cameraHelper.removeSurface(currentSurface); + } + } + + @Override + public void onDeviceClose(@NonNull UsbDevice device) { + } + + @Override + public void onDetach(@NonNull UsbDevice device) { + if (currentDevice != null && currentDevice.getDeviceId() == device.getDeviceId()) { + currentDevice = null; + isPreviewing = false; + postEvent("detached", deviceToMap(device)); + } + } + + @Override + public void onCancel(@NonNull UsbDevice device) { + // UVCAndroid 内部权限被拒(通常被 UsbPermissionReceiver 拦截,此处兜底) + completeOpen("用户拒绝了 USB 权限,请点击「重试」并在弹窗中选择「允许」"); + postEvent("cancelled", deviceToMap(device)); + } + }; + + // ---------- 预览启动 ---------- + + private void startPreview() { + if (isPreviewing) return; + cameraHelper.startPreview(); + isPreviewing = true; + if (currentSurface != null && currentSurface.isValid()) { + cameraHelper.addSurface(currentSurface, false); + } + } + + // ---------- MethodChannel 异步回调 ---------- + + private void completeOpen(@Nullable String error) { + if (pendingOpenResult == null) return; + final MethodChannel.Result result = pendingOpenResult; + pendingOpenResult = null; + mainHandler.post(() -> { + try { + if (error == null) { + result.success(null); + } else { + result.error("OPEN_FAILED", error, null); + } + } catch (IllegalStateException ignored) { + // Flutter 框架已回复,重复调用无效,忽略 + } + }); + } + + // ---------- EventChannel.StreamHandler ---------- + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + eventSink = events; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + + private void postEvent(@NonNull String type, @NonNull Map data) { + mainHandler.post(() -> { + if (eventSink == null) return; + Map event = new HashMap<>(); + event.put("type", type); + event.put("device", data); + eventSink.success(event); + }); + } +} diff --git a/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraView.java b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraView.java new file mode 100644 index 00000000..590c50e1 --- /dev/null +++ b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraView.java @@ -0,0 +1,49 @@ +package com.maibu.usb_camera_android; + +import android.content.Context; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; + +import androidx.annotation.NonNull; + +import io.flutter.plugin.platform.PlatformView; + +public class UsbCameraView implements PlatformView { + private final SurfaceView surfaceView; + + public UsbCameraView(Context context, UsbCameraEngine engine) { + surfaceView = new SurfaceView(context); + surfaceView.setZOrderMediaOverlay(true); + surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(@NonNull SurfaceHolder holder) { + engine.addSurface(holder.getSurface()); + } + + @Override + public void surfaceChanged( + @NonNull SurfaceHolder holder, int format, int width, int height) { + } + + @Override + public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + engine.removeSurface(holder.getSurface()); + } + }); + } + + @Override + public View getView() { + return surfaceView; + } + + @Override + public void dispose() { + Surface surface = surfaceView.getHolder().getSurface(); + if (surface != null) { + surface.release(); + } + } +} diff --git a/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraViewFactory.java b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraViewFactory.java new file mode 100644 index 00000000..397307ca --- /dev/null +++ b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbCameraViewFactory.java @@ -0,0 +1,27 @@ +package com.maibu.usb_camera_android; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; + +public class UsbCameraViewFactory extends PlatformViewFactory { + private final UsbCameraEngine engine; + + public UsbCameraViewFactory(@NonNull UsbCameraEngine engine) { + super(StandardMessageCodec.INSTANCE); + this.engine = engine; + } + + @NonNull + @Override + public PlatformView create( + @NonNull Context context, int id, @Nullable Object args) { + return new UsbCameraView(context, engine); + } +} diff --git a/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbPermissionReceiver.java b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbPermissionReceiver.java new file mode 100644 index 00000000..4ca422c6 --- /dev/null +++ b/packages/usb_camera_android/android/src/main/java/com/maibu/usb_camera_android/UsbPermissionReceiver.java @@ -0,0 +1,72 @@ +package com.maibu.usb_camera_android; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.os.Build; + +/** + * USB 权限结果广播接收器(manifest 静态注册)。 + * + * 为什么必须用静态 Receiver 而不能用动态注册的 BroadcastReceiver: + * - Android 14 (API 34+) 禁止 FLAG_MUTABLE + 隐式 Intent 组合,直接抛 + * IllegalArgumentException("Targeting U+ disallows creating a PendingIntent + * with FLAG_MUTABLE, an implicit Intent...")。 + * - 动态 BroadcastReceiver 无法在 Intent 里指定目标 class,只能是隐式 → + * FLAG_MUTABLE 在 Android 14+ 崩溃;FLAG_IMMUTABLE 又会导致系统无法注入 + * EXTRA_DEVICE / EXTRA_PERMISSION_GRANTED,权限结果永远读不到。 + * - 静态 Receiver 在 manifest 声明,可用 new Intent(context, 本类.class) + * 构造显式 Intent → FLAG_MUTABLE 合法 → 系统正常注入 extras。 + * + * 权限结果通过一次性静态回调 sCallback 传给 UsbCameraEngine,收到后立刻清空。 + */ +public class UsbPermissionReceiver extends BroadcastReceiver { + + static final String ACTION_USB_PERMISSION = + "com.maibu.usb_camera_android.USB_PERMISSION"; + + interface Callback { + void onResult(UsbDevice device, boolean granted); + } + + /** 同一时刻只有一个等待中的权限请求 */ + static volatile Callback sCallback; + + @Override + public void onReceive(Context context, Intent intent) { + if (!ACTION_USB_PERMISSION.equals(intent.getAction())) return; + + @SuppressWarnings("deprecation") + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + boolean granted = + intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + + Callback cb = sCallback; + sCallback = null; // 一次性,防止旧回调串场 + if (cb != null && device != null) { + cb.onResult(device, granted); + } + } + + /** + * 构造 Android 14+ 合法的 USB 权限 PendingIntent。 + * + * 显式 Intent(指定 UsbPermissionReceiver.class) + * → 允许搭配 FLAG_MUTABLE → 系统可注入权限结果 extras。 + * API 30 及以下 PendingIntent 默认可变,不需要显式 FLAG_MUTABLE。 + */ + static PendingIntent createPermissionIntent(Context context, UsbDevice device) { + Intent intent = new Intent(context, UsbPermissionReceiver.class); + intent.setAction(ACTION_USB_PERMISSION); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // API 31+ + flags |= PendingIntent.FLAG_MUTABLE; + } + return PendingIntent.getBroadcast( + context, device.getDeviceId(), intent, flags); + } +} diff --git a/packages/usb_camera_android/lib/src/usb_camera_android.dart b/packages/usb_camera_android/lib/src/usb_camera_android.dart new file mode 100644 index 00000000..02fefd9d --- /dev/null +++ b/packages/usb_camera_android/lib/src/usb_camera_android.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +/// 原生端返回的 UVC 设备信息。 +class UsbCameraDeviceInfo { + const UsbCameraDeviceInfo({ + required this.deviceId, + required this.vendorId, + required this.productId, + this.productName, + }); + + final String deviceId; + final int vendorId; + final int productId; + final String? productName; + + factory UsbCameraDeviceInfo.fromMap(Map map) { + return UsbCameraDeviceInfo( + deviceId: map['deviceId'] as String, + vendorId: map['vendorId'] as int, + productId: map['productId'] as int, + productName: map['productName'] as String?, + ); + } +} + +class UsbCameraAndroid { + static const MethodChannel _methodChannel = + MethodChannel('com.maibu.usb_camera_android/methods'); + static const EventChannel _eventChannel = + EventChannel('com.maibu.usb_camera_android/events'); + + static Stream>? _eventStream; + + /// 原生端事件流: + /// - attached / detached:设备插拔 + /// - opened:摄像头已打开并开始预览 + /// - cancelled:用户拒绝 USB 权限 + static Stream> get eventStream { + _eventStream ??= _eventChannel + .receiveBroadcastStream() + .cast>() + .map((e) => Map.from(e)); + return _eventStream!; + } + + /// 枚举当前连接的 UVC 设备。 + static Future> getDeviceList() async { + final List result = + await _methodChannel.invokeMethod('getDeviceList'); + return result + .map((e) => UsbCameraDeviceInfo.fromMap(e as Map)) + .toList(); + } + + /// 获取当前已打开摄像头支持的分辨率列表。 + static Future> getSupportedResolutions() async { + final List result = + await _methodChannel.invokeMethod('getSupportedResolutions'); + return result.cast(); + } + + /// 获取当前预览尺寸(摄像头已打开后有效),返回 (width, height);未打开返回 null。 + static Future<({int width, int height})?> getPreviewSize() async { + final result = await _methodChannel.invokeMethod('getPreviewSize'); + if (result == null) return null; + final map = Map.from(result as Map); + return (width: map['width'] as int, height: map['height'] as int); + } + + /// 打开指定设备。 + static Future open( + String deviceId, { + String? resolution, + }) async { + await _methodChannel.invokeMethod('open', { + 'deviceId': deviceId, + 'resolution': resolution, + }); + } + + /// 关闭当前设备。 + static Future close() async { + await _methodChannel.invokeMethod('close'); + } + + /// 切换分辨率。 + static Future setResolution(String resolution) async { + await _methodChannel.invokeMethod('setResolution', { + 'resolution': resolution, + }); + } +} diff --git a/packages/usb_camera_android/lib/src/usb_camera_preview_view.dart b/packages/usb_camera_android/lib/src/usb_camera_preview_view.dart new file mode 100644 index 00000000..8d0aa730 --- /dev/null +++ b/packages/usb_camera_android/lib/src/usb_camera_preview_view.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Android 原生 UVC 预览画面。 +class UsbCameraPreviewView extends StatelessWidget { + const UsbCameraPreviewView({super.key}); + + static const String _viewType = 'com.maibu.usb_camera_android/preview'; + + @override + Widget build(BuildContext context) { + return const AndroidView( + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: StandardMessageCodec(), + ); + } +} diff --git a/packages/usb_camera_android/lib/usb_camera_android.dart b/packages/usb_camera_android/lib/usb_camera_android.dart new file mode 100644 index 00000000..d1e3a3ea --- /dev/null +++ b/packages/usb_camera_android/lib/usb_camera_android.dart @@ -0,0 +1,2 @@ +export 'src/usb_camera_android.dart'; +export 'src/usb_camera_preview_view.dart'; diff --git a/packages/usb_camera_android/pubspec.yaml b/packages/usb_camera_android/pubspec.yaml new file mode 100644 index 00000000..3f69ec88 --- /dev/null +++ b/packages/usb_camera_android/pubspec.yaml @@ -0,0 +1,19 @@ +name: usb_camera_android +description: Local Android plugin for USB UVC camera preview. +version: 0.1.0 +publish_to: "none" + +environment: + sdk: ^3.10.0 + flutter: ">=3.10.0" + +flutter: + plugin: + platforms: + android: + package: com.maibu.usb_camera_android + pluginClass: UsbCameraAndroidPlugin + +dependencies: + flutter: + sdk: flutter diff --git a/packages/video_player_android/AUTHORS b/packages/video_player_android/AUTHORS new file mode 100644 index 00000000..07a1e9f7 --- /dev/null +++ b/packages/video_player_android/AUTHORS @@ -0,0 +1,68 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Márton Matuz +André Sousa diff --git a/packages/video_player_android/CHANGELOG.md b/packages/video_player_android/CHANGELOG.md new file mode 100644 index 00000000..85f40631 --- /dev/null +++ b/packages/video_player_android/CHANGELOG.md @@ -0,0 +1,391 @@ +## 2.9.5 + +* Updates build files from Groovy to Kotlin. + +## 2.9.4 + +* Updates `androidx.media3` to 1.9.2. + +## 2.9.3 + +* Updates `androidx.media3` to 1.9.1. + +## 2.9.2 + +* Bumps kotlin_version to 2.3.0. + +## 2.9.1 + +* Updates to Pigeon 26.1.5. + +## 2.9.0 + +* Implements `getAudioTracks()` and `selectAudioTrack()` methods for Android using ExoPlayer. + +## 2.8.22 + +* Bumps kotlin_version to 2.2.21. + +## 2.8.21 + +* Bumps com.android.tools.build:gradle from 8.12.1 to 8.13.1. + +## 2.8.20 + +* Bumps `exoplayer_version` from 1.5.1 to 1.8.0. + +## 2.8.19 + +* Updates to Pigeon 26. + +## 2.8.18 + +* Removed mockito-inline and updated to mockito-core 5.17.0. + +## 2.8.17 + +* Moves video event processing logic to Dart, and fixes an issue where buffer + range would not be updated for a paused video. +* Switches to Kotlin for Pigeon-generated code. +* Adopts type-safe event channels for internal communication. + +## 2.8.16 + +* Updates Java compatibility version to 17 and minimum supported SDK version to Flutter 3.35/Dart 3.9. + +## 2.8.15 + +* Resolves Gradle 9 deprecations. + +## 2.8.14 + +* Restructures internal logic for player creation and tracking. + +## 2.8.13 + +* Bumps com.android.tools.build:gradle to 8.12.1. + +## 2.8.12 + +* Moves buffer position update event generation to Dart. + +## 2.8.11 + +* Updates kotlin version to 2.2.0 to enable gradle 8.11 support. + +## 2.8.10 + +* Restructures internal logic to move more code to Dart. + +## 2.8.9 + +* Restructures the communication between Dart and Java code. + +## 2.8.8 + +* * Updates Media3-ExoPlayer to 1.5.1. + +## 2.8.7 + +* Adds note about known issue regarding platform views to the README. + +## 2.8.6 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/165149) where the `isBuffering` flag + always returns true after seeking to any position. + +## 2.8.5 + +* Replaces deprecated `onSurfaceDestroyed` with `onSurfaceCleanup`. +* Restores background playback support. + +## 2.8.4 + +* Fixes incorrect width/height swap ([bug](https://github.com/flutter/flutter/issues/166097)). The swap was originally required for the uncorrected width/height of `Format` but was mistakenly retained after [switching to `VideoSize`](https://github.com/flutter/packages/pull/6535), which already accounts for rotation. +* Fixes example app layout issue caused by `Transform.rotate` not affecting space calculation (following [#8685](https://github.com/flutter/packages/pull/8685)). + +## 2.8.3 + +* Changes plugin to use `TextureRegistry.SurfaceProducer.handlesCropAndRotation` to detect + whether or not the video player rotation needs to be corrected. + +## 2.8.2 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/164689) that can cause video to + disappear after an app is suspended and resumed. + +## 2.8.1 + +* Updates compileSdk 34 to flutter.compileSdkVersion. + +## 2.8.0 + +* Adds support for platform views as an optional way of displaying a video. +* Suppresses deprecation and removal warnings for + `TextureRegistry.SurfaceProducer.onSurfaceDestroyed`. + +## 2.7.17 + +* Replaces deprecated Android embedder APIs (`onSurfaceCreated` -> `onSurfaceAvailable`). +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. + +## 2.7.16 + +* Updates internal Pigeon API to use newer features. + +## 2.7.15 + +* Changes the rotation correction calculation for Android API 29+ to use + the one that is reported by the video's format instead of the unapplied + rotation degrees that Exoplayer does not report on Android API 21+. +* Changes the rotation correction calculation for Android APIs 21-28 to 0 + because the Impeller backend used on those API versions correctly rotates + the video being played automatically. + +## 2.7.14 + +* Removes SSL workaround for API 19, which is no longer supported. + +## 2.7.13 + +* When `AndroidVideoPlayer` attempts to operate on a `textureId` that is not + active (i.e. it was previously disposed or never created), the resulting + platform exception is more informative than a "NullPointerException". + +## 2.7.12 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/156451) where + additional harmless but annoying warnings in the form of native stack traces + would be printed when the app was backgrounded. There may be additional + warnings that are not yet fixed, but this should address the + most common case. + +## 2.7.11 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/156158) where a + harmless but annoying warning in the form of a native stack trace would be + printed when a previously disposed video player received a trim memory event + (i.e. by backgrounding). + +## 2.7.10 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/156158) where + disposing a video player (including implicitly by switching tabs or views + in a running app) would cause native stack traces. + +## 2.7.9 + +* Updates Java compatibility version to 11. + +## 2.7.8 + +* Updates Pigeon for non-nullable collection type support. + +## 2.7.7 + +* Removes the flag to treat warnings as errors in client builds. + +## 2.7.6 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/154602) where + resuming a video player would cause a `Bad state: Future already completed`. + +## 2.7.5 + +* Add a deprecation suppression in advance of a new `SurfaceProducer` API. + +## 2.7.4 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/154559) where + resuming (or using a plugin like `share_plus` that implicitly resumes the + activity where) a video player would cause a `DecoderInitializationException`. + +## 2.7.3 + +* Updates Media3-ExoPlayer to 1.4.1. + +## 2.7.2 + +* Updates minimum supported SDK version to Flutter 3.24/Dart 3.5. +* Re-adds Impeller support. + +## 2.7.1 + +* Revert Impeller support. + +## 2.7.0 + +* Re-adds [support for Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). + +## 2.6.0 + +* Adds RTSP support. + +## 2.5.4 + +* Updates Media3-ExoPlayer to 1.4.0. + +## 2.5.3 + +* Updates lint checks to ignore NewerVersionAvailable. + +## 2.5.2 + +* Updates Android Gradle plugin to 8.5.0. + +## 2.5.1 + +* Removes additional references to the v1 Android embedding. + +## 2.5.0 + +* Migrates ExoPlayer to Media3-ExoPlayer 1.3.1. + +## 2.4.17 + +* Revert Impeller support. + +## 2.4.16 + +* [Supports Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). + +## 2.4.15 + +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Removes support for apps using the v1 Android embedding. + +## 2.4.14 + +* Calls `onDestroy` instead of `initialize` in onDetachedFromEngine. + +## 2.4.13 + +* Updates minSdkVersion to 19. +* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. + +## 2.4.12 + +* Updates compileSdk version to 34. +* Adds error handling for `BehindLiveWindowException`, which may occur upon live-video playback failure. + +## 2.4.11 + +* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. +* Fixes new lint warnings. + +## 2.4.10 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. + +## 2.4.9 + +* Bumps ExoPlayer version to 2.18.7. + +## 2.4.8 + +* Bumps ExoPlayer version to 2.18.6. + +## 2.4.7 + +* Fixes Java warnings. + +## 2.4.6 + +* Fixes compatibility with AGP versions older than 4.2. + +## 2.4.5 + +* Adds a namespace for compatibility with AGP 8.0. + +## 2.4.4 + +* Synchronizes `VideoPlayerValue.isPlaying` with `ExoPlayer`. +* Updates minimum Flutter version to 3.3. + +## 2.4.3 + +* Bumps ExoPlayer version to 2.18.5. + +## 2.4.2 + +* Bumps ExoPlayer version to 2.18.4. + +## 2.4.1 + +* Changes the severity of `javac` warnings so that they are treated as errors and fixes the violations. + +## 2.4.0 + +* Allows setting the ExoPlayer user agent by passing a User-Agent HTTP header. + +## 2.3.12 + +* Clarifies explanation of endorsement in README. +* Aligns Dart and Flutter SDK constraints. +* Updates compileSdkVersion to 33. + +## 2.3.11 + +* Updates links for the merge of flutter/plugins into flutter/packages. +* Updates minimum Flutter version to 3.0. + +## 2.3.10 + +* Adds compatibilty with version 6.0 of the platform interface. +* Fixes file URI construction in example. +* Updates code for new analysis options. +* Updates code for `no_leading_underscores_for_local_identifiers` lint. +* Updates minimum Flutter version to 2.10. +* Fixes violations of new analysis option use_named_constants. +* Removes an unnecessary override in example code. + +## 2.3.9 + +* Updates ExoPlayer to 2.18.1. +* Fixes avoid_redundant_argument_values lint warnings and minor typos. + +## 2.3.8 + +* Updates ExoPlayer to 2.18.0. + +## 2.3.7 + +* Bumps gradle version to 7.2.1. +* Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). + +## 2.3.6 + +* Updates references to the obsolete master branch. + +## 2.3.5 + +* Sets rotationCorrection for videos recorded in landscapeRight (https://github.com/flutter/flutter/issues/60327). + +## 2.3.4 + +* Updates ExoPlayer to 2.17.1. + +## 2.3.3 + +* Removes unnecessary imports. +* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors + lint warnings. + +## 2.3.2 + +* Updates ExoPlayer to 2.17.0. + +## 2.3.1 + +* Renames internal method channels to avoid potential confusion with the + default implementation's method channel. +* Updates Pigeon to 2.0.1. + +## 2.3.0 + +* Updates Pigeon to ^1.0.16. + +## 2.2.17 + +* Splits from `video_player` as a federated implementation. diff --git a/packages/video_player_android/LICENSE b/packages/video_player_android/LICENSE new file mode 100644 index 00000000..29b709da --- /dev/null +++ b/packages/video_player_android/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/video_player_android/android/build.gradle.kts b/packages/video_player_android/android/build.gradle.kts new file mode 100644 index 00000000..4f7eccc4 --- /dev/null +++ b/packages/video_player_android/android/build.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +group = "io.flutter.plugins.videoplayer" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.3.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.13.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("kotlin-android") +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget(JavaVersion.VERSION_17.toString()) + } +} + +android { + namespace = "io.flutter.plugins.videoplayer" + compileSdk = flutter.compileSdkVersion + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + checkAllWarnings = true + warningsAsErrors = true + disable.addAll(setOf("AndroidGradlePluginVersion", "InvalidPackage", "GradleDependency", "NewerVersionAvailable")) + baseline = file("lint-baseline.xml") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + dependencies { + val exoplayerVersion = "1.9.2" + implementation("androidx.media3:media3-exoplayer:${exoplayerVersion}") + implementation("androidx.media3:media3-exoplayer-hls:${exoplayerVersion}") + implementation("androidx.media3:media3-exoplayer-dash:${exoplayerVersion}") + implementation("androidx.media3:media3-exoplayer-rtsp:${exoplayerVersion}") + implementation("androidx.media3:media3-exoplayer-smoothstreaming:${exoplayerVersion}") + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test:core:1.7.0") + testImplementation("org.mockito:mockito-core:5.23.0") + testImplementation("org.robolectric:robolectric:4.16") + testImplementation("androidx.media3:media3-test-utils:${exoplayerVersion}") + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + // The org.gradle.jvmargs property that may be set in gradle.properties does not impact + // the Java heap size when running the Android unit tests. The following property here + // sets the heap size to a size large enough to run the robolectric tests across + // multiple SDK levels. + it.jvmArgs("-Xmx4G") + } + } + } +} diff --git a/packages/video_player_android/android/lint-baseline.xml b/packages/video_player_android/android/lint-baseline.xml new file mode 100644 index 00000000..f13a3360 --- /dev/null +++ b/packages/video_player_android/android/lint-baseline.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/packages/video_player_android/android/settings.gradle.kts b/packages/video_player_android/android/settings.gradle.kts new file mode 100644 index 00000000..e3289a05 --- /dev/null +++ b/packages/video_player_android/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "video_player_android" diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java new file mode 100644 index 00000000..33988786 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java @@ -0,0 +1,124 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.Tracks; +import androidx.media3.exoplayer.ExoPlayer; + +public abstract class ExoPlayerEventListener implements Player.Listener { + private boolean isInitialized = false; + protected final ExoPlayer exoPlayer; + protected final VideoPlayerCallbacks events; + + protected enum RotationDegrees { + ROTATE_0(0), + ROTATE_90(90), + ROTATE_180(180), + ROTATE_270(270); + + private final int degrees; + + RotationDegrees(int degrees) { + this.degrees = degrees; + } + + public static RotationDegrees fromDegrees(int degrees) { + for (RotationDegrees rotationDegrees : RotationDegrees.values()) { + if (rotationDegrees.degrees == degrees) { + return rotationDegrees; + } + } + throw new IllegalArgumentException("Invalid rotation degrees specified: " + degrees); + } + + public int getDegrees() { + return this.degrees; + } + } + + public ExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events) { + this.exoPlayer = exoPlayer; + this.events = events; + } + + protected abstract void sendInitialized(); + + @Override + public void onPlaybackStateChanged(final int playbackState) { + PlatformPlaybackState platformState = PlatformPlaybackState.UNKNOWN; + switch (playbackState) { + case Player.STATE_BUFFERING: + platformState = PlatformPlaybackState.BUFFERING; + break; + case Player.STATE_READY: + platformState = PlatformPlaybackState.READY; + if (!isInitialized) { + isInitialized = true; + sendInitialized(); + } + break; + case Player.STATE_ENDED: + platformState = PlatformPlaybackState.ENDED; + break; + case Player.STATE_IDLE: + platformState = PlatformPlaybackState.IDLE; + break; + } + events.onPlaybackStateChanged(platformState); + } + + @Override + public void onPlayerError(@NonNull final PlaybackException error) { + if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { + // See + // https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window + exoPlayer.seekToDefaultPosition(); + exoPlayer.prepare(); + } else { + events.onError("VideoError", "Video player had error " + error, null); + } + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + events.onIsPlayingStateUpdate(isPlaying); + } + + @Override + public void onTracksChanged(@NonNull Tracks tracks) { + // Find the currently selected audio track and notify + String selectedTrackId = findSelectedAudioTrackId(tracks); + events.onAudioTrackChanged(selectedTrackId); + } + + /** + * Finds the ID of the currently selected audio track. + * + * @param tracks The current tracks + * @return The track ID in format "groupIndex_trackIndex", or null if no audio track is selected + */ + @Nullable + private String findSelectedAudioTrackId(@NonNull Tracks tracks) { + int groupIndex = 0; + for (Tracks.Group group : tracks.getGroups()) { + if (group.getType() == C.TRACK_TYPE_AUDIO && group.isSelected()) { + // Find the selected track within this group + for (int i = 0; i < group.length; i++) { + if (group.isTrackSelected(i)) { + return groupIndex + "_" + i; + } + } + } + groupIndex++; + } + return null; + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java new file mode 100644 index 00000000..0c85e0e8 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java @@ -0,0 +1,94 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import java.util.Map; + +final class HttpVideoAsset extends VideoAsset { + @NonNull private final StreamingFormat streamingFormat; + @NonNull private final Map httpHeaders; + @Nullable private final String userAgent; + + HttpVideoAsset( + @Nullable String assetUrl, + @NonNull StreamingFormat streamingFormat, + @NonNull Map httpHeaders, + @Nullable String userAgent) { + super(assetUrl); + this.streamingFormat = streamingFormat; + this.httpHeaders = httpHeaders; + this.userAgent = userAgent; + } + + @NonNull + @Override + public MediaItem getMediaItem() { + MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl); + String mimeType = null; + switch (streamingFormat) { + case SMOOTH: + mimeType = MimeTypes.APPLICATION_SS; + break; + case DYNAMIC_ADAPTIVE: + mimeType = MimeTypes.APPLICATION_MPD; + break; + case HTTP_LIVE: + mimeType = MimeTypes.APPLICATION_M3U8; + break; + } + if (mimeType != null) { + builder.setMimeType(mimeType); + } + return builder.build(); + } + + @NonNull + @Override + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) { + return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory()); + } + + /** + * Returns a configured media source factory, starting at the provided factory. + * + *

This method is provided for ease of testing without making real HTTP calls. + * + * @param context application context. + * @param initialFactory initial factory, to be configured. + * @return configured factory, or {@code null} if not needed for this asset type. + */ + @VisibleForTesting + MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory initialFactory) { + unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent); + DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, initialFactory); + return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @OptIn(markerClass = UnstableApi.class) + private static void unstableUpdateDataSourceFactory( + @NonNull DefaultHttpDataSource.Factory factory, + @NonNull Map httpHeaders, + @Nullable String userAgent) { + factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true); + if (!httpHeaders.isEmpty()) { + factory.setDefaultRequestProperties(httpHeaders); + } + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java new file mode 100644 index 00000000..0644bbb9 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java @@ -0,0 +1,29 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; + +final class LocalVideoAsset extends VideoAsset { + LocalVideoAsset(@NonNull String assetUrl) { + super(assetUrl); + } + + @NonNull + @Override + public MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); + } + + @NonNull + @Override + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) { + return new DefaultMediaSourceFactory(context); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/PlaybackTuning.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/PlaybackTuning.java new file mode 100644 index 00000000..32592727 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/PlaybackTuning.java @@ -0,0 +1,223 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that should be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 直播播放调参(本项目本地覆盖新增,用于花屏/延迟现场 A/B 试验)。 + * + *

这些值由 App 侧在 {@code VideoPlayerController.initialize()} 之前通过 MethodChannel 下发, + * 播放器等创建时读取,因此**改动只对"下一次拉流"生效**,不需要重新编译。 + * + *

字段保持默认值时,行为与未引入本类时一致(minBuffer 500 / maxBuffer 3000 / 起播 0 / UDP)。 + */ +public final class PlaybackTuning { + private static final String TAG = "PlaybackTuning"; + + /** App 与插件约定的通道名。 */ + public static final String CHANNEL = "plugins.flutter.dev/video_player_android/tuning"; + + /** + * RTSP 的 RTP 传输方式。{@code false} = UDP(Media3 默认,延迟低但空口丢包直接花屏); {@code true} = + * TCP interleaved(丢包由 TCP 重传兜住,画面完整,代价是弱链路下卡顿、延迟上升)。 + */ + public static volatile boolean forceRtpTcp = false; + + /** + * {@code DefaultLoadControl} 的 minBufferMs(= Loader 重新开始加载的水位)。 注意:Media3 内部有 500ms + * 硬下限({@code minBufferUs = max(minBufferUs, 500_000)}), 因此填小于 500 的值不会有任何效果。 + */ + public static volatile int minBufferMs = 500; + + /** + * {@code DefaultLoadControl} 的 maxBufferMs(= Loader 停止加载的水位,即稳态积压上限)。 加载水位在 + * minBufferMs..maxBufferMs 之间锯齿,稳态平均积压约为两者中点, 所以这是"画面延迟"最直接的可调项。 + */ + public static volatile int maxBufferMs = 3000; + + /** 起播所需缓冲:直播保持 0,首帧到达即渲染。 */ + public static volatile int bufferForPlaybackMs = 0; + + /** rebuffer 后恢复播放所需缓冲:直播保持 0,避免每次卡完再攒一段。 */ + public static volatile int bufferForPlaybackAfterRebufferMs = 0; + + /** + * RtspMediaSource 的超时(毫秒)。0 = 使用 Media3 默认值。 它同时影响"UDP 无数据时改用 TCP 重试"以及"多久没收到数据判为流结束", + * 设得过小会在弱链路上误判断流。 + */ + public static volatile int rtspTimeoutMs = 0; + + /** + * {@code DefaultLoadControl.setTargetBufferBytes}:按字节封顶的加载上限。 0 = 不覆盖,用 Media3 默认(视频约 2MB)。 + * + *

为什么直播也要管它:加载器是"时间水位到了或字节数到了"就停止加载。子码流下 2MB 相当于十几秒, 时间水位先撞上,字节上限无关; + * 但换主码流/高码率后 2MB 可能不到 1 秒,于是"看起来只缓冲了几百 ms 就不再加载" → 频繁 rebuffer。 高码率场景要把它调大(如 8~16MB)。 + */ + public static volatile int targetBufferBytes = 0; + + /** 解码方式:硬件优先(Media3 默认)。 */ + public static final int DECODER_HARDWARE_PREFERRED = 0; + + /** 解码方式:只用软件解码器。用于排查"硬解对损坏码流的容错差异",代价是功耗与高分辨率下跟不上。 */ + public static final int DECODER_SOFTWARE_ONLY = 1; + + /** 解码方式:硬件优先,初始化失败时回退软件解码。 */ + public static final int DECODER_HARDWARE_WITH_FALLBACK = 2; + + /** + * 解码方式(见上面三个常量)。Media3 不传 RenderersFactory 时就是 {@code DefaultRenderersFactory} + {@code + * MediaCodecSelector.DEFAULT},硬件优先,且硬解初始化失败不会自动回退软解。 + */ + public static volatile int decoderMode = DECODER_HARDWARE_PREFERRED; + + private PlaybackTuning() {} + + /** + * 注册调参通道。由 {@link VideoPlayerPlugin#onAttachedToEngine} 调用。 + * + * @param messenger 引擎的 BinaryMessenger。 + */ + public static void setUp(@NonNull BinaryMessenger messenger) { + MethodChannel channel = new MethodChannel(messenger, CHANNEL); + channel.setMethodCallHandler( + (MethodCall call, MethodChannel.Result result) -> { + switch (call.method) { + case "set": { + applyArgs(call.arguments()); + result.success(describe()); + break; + } + case "get": + result.success(describe()); + break; + case "reset": + resetDefaults(); + result.success(describe()); + break; + default: + result.notImplemented(); + } + }); + } + + /** 注销通道(engine detach 时调用,避免残留 handler)。 */ + public static void tearDown(@NonNull BinaryMessenger messenger) { + new MethodChannel(messenger, CHANNEL).setMethodCallHandler(null); + } + + private static void resetDefaults() { + forceRtpTcp = false; + minBufferMs = 500; + maxBufferMs = 3000; + bufferForPlaybackMs = 0; + bufferForPlaybackAfterRebufferMs = 0; + rtspTimeoutMs = 0; + targetBufferBytes = 0; + decoderMode = DECODER_HARDWARE_PREFERRED; + } + + @SuppressWarnings("unchecked") + private static void applyArgs(@Nullable Object rawArgs) { + if (!(rawArgs instanceof Map)) { + Log.w(TAG, "set 参数不是 Map,忽略:" + rawArgs); + return; + } + Map args = (Map) rawArgs; + if (args.containsKey("forceRtpTcp")) { + forceRtpTcp = Boolean.TRUE.equals(args.get("forceRtpTcp")); + } + minBufferMs = intArg(args, "minBufferMs", minBufferMs); + maxBufferMs = intArg(args, "maxBufferMs", maxBufferMs); + bufferForPlaybackMs = intArg(args, "bufferForPlaybackMs", bufferForPlaybackMs); + bufferForPlaybackAfterRebufferMs = + intArg(args, "bufferForPlaybackAfterRebufferMs", bufferForPlaybackAfterRebufferMs); + rtspTimeoutMs = intArg(args, "rtspTimeoutMs", rtspTimeoutMs); + targetBufferBytes = intArg(args, "targetBufferBytes", targetBufferBytes); + decoderMode = intArg(args, "decoderMode", decoderMode); + + // Media3 构造函数里有 assertGreaterOrEqual(maxBufferMs, minBufferMs), + // 这里提前夹紧,避免 App 填出非法组合直接抛异常导致黑屏。 + if (maxBufferMs < minBufferMs) { + maxBufferMs = minBufferMs; + } + Log.i(TAG, "已应用调参 -> " + describe()); + } + + private static int intArg(Map args, String key, int fallback) { + Object v = args.get(key); + if (v instanceof Number) { + return ((Number) v).intValue(); + } + return fallback; + } + + /** 当前生效值,用于回显给 App 侧。 */ + public static Map describe() { + Map out = new HashMap<>(); + out.put("forceRtpTcp", forceRtpTcp); + out.put("minBufferMs", minBufferMs); + out.put("maxBufferMs", maxBufferMs); + out.put("bufferForPlaybackMs", bufferForPlaybackMs); + out.put("bufferForPlaybackAfterRebufferMs", bufferForPlaybackAfterRebufferMs); + out.put("rtspTimeoutMs", rtspTimeoutMs); + out.put("targetBufferBytes", targetBufferBytes); + out.put("decoderMode", decoderMode); + return out; + } + + /** + * 按当前 {@link #decoderMode} 构造 RenderersFactory,供两处 ExoPlayer.Builder 共用。 + * + * @param context application context。 + * @return 配置好的 RenderersFactory。 + */ + @UnstableApi + @NonNull + public static DefaultRenderersFactory buildRenderersFactory(@NonNull Context context) { + DefaultRenderersFactory factory = new DefaultRenderersFactory(context); + // 硬解初始化失败是否回退软解。Media3 默认 false:机型缺对应硬解时直接报错黑屏, + // 而不是慢慢软解。H265 在部分机型上只有软解,打开这一档能"播得上"但可能跟不上。 + factory.setEnableDecoderFallback(decoderMode == DECODER_HARDWARE_WITH_FALLBACK); + if (decoderMode == DECODER_SOFTWARE_ONLY) { + factory.setMediaCodecSelector(SOFTWARE_ONLY_SELECTOR); + } + Log.i(TAG, "RenderersFactory: decoderMode=" + decoderMode); + return factory; + } + + /** 只保留软件解码器;若过滤后为空(极少数 ROM 报不出软解)则退回原列表,保证不播不出画面。 */ + private static final MediaCodecSelector SOFTWARE_ONLY_SELECTOR = + (String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) -> { + List all = + MediaCodecSelector.DEFAULT.getDecoderInfos( + mimeType, requiresSecureDecoder, requiresTunnelingDecoder); + List software = new ArrayList<>(); + for (MediaCodecInfo info : all) { + // 注意:Media3 1.x 的 softwareOnly / hardwareAccelerated 是字段,不是 getter。 + if (info.softwareOnly) { + software.add(info); + } + } + Log.i( + TAG, + "软解筛选 " + mimeType + ": 候选" + all.size() + " 个,软解" + software.size() + " 个"); + return software.isEmpty() ? all : software; + }; +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/QueuingEventSink.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/QueuingEventSink.java new file mode 100644 index 00000000..6e7db82d --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/QueuingEventSink.java @@ -0,0 +1,81 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import java.util.ArrayList; + +/** + * A wrapper for {@link PigeonEventSink} which can queue messages. + * + *

It delivers messages immediately when downstream is available, but it queues messages before + * the delegate event sink is set with setDelegate. + * + *

This class is not thread-safe. All calls must be done on the same thread or synchronized + * externally. + */ +final class QueuingEventSink { + private PigeonEventSink delegate; + private final ArrayList eventQueue = new ArrayList<>(); + private boolean done = false; + + public void setDelegate(PigeonEventSink delegate) { + this.delegate = delegate; + maybeFlush(); + } + + public void endOfStream() { + enqueue(new EndOfStreamEvent()); + maybeFlush(); + done = true; + } + + public void error(String code, String message, Object details) { + enqueue(new ErrorEvent(code, message, details)); + maybeFlush(); + } + + public void success(PlatformVideoEvent event) { + enqueue(event); + maybeFlush(); + } + + private void enqueue(Object event) { + if (done) { + return; + } + eventQueue.add(event); + } + + private void maybeFlush() { + if (delegate == null) { + return; + } + for (Object event : eventQueue) { + if (event instanceof EndOfStreamEvent) { + delegate.endOfStream(); + } else if (event instanceof ErrorEvent) { + ErrorEvent errorEvent = (ErrorEvent) event; + delegate.error(errorEvent.code, errorEvent.message, errorEvent.details); + } else { + delegate.success((PlatformVideoEvent) event); + } + } + eventQueue.clear(); + } + + static class EndOfStreamEvent {} + + private static class ErrorEvent { + String code; + String message; + Object details; + + ErrorEvent(String code, String message, Object details) { + this.code = code; + this.message = message; + this.details = details; + } + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java new file mode 100644 index 00000000..38d70727 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java @@ -0,0 +1,42 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.rtsp.RtspMediaSource; +import androidx.media3.exoplayer.source.MediaSource; + +final class RtspVideoAsset extends VideoAsset { + RtspVideoAsset(@NonNull String assetUrl) { + super(assetUrl); + } + + @NonNull + @Override + public MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @OptIn(markerClass = UnstableApi.class) + @Override + @NonNull + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) { + RtspMediaSource.Factory factory = new RtspMediaSource.Factory(); + // 本项目本地覆盖新增:两个策略可现场切换,默认值保持 Media3 原有行为。 + // forceRtpTcp=false → UDP(Media3 默认):延迟低,但图传/空口丢包直接表现为花屏; + // forceRtpTcp=true → TCP interleaved:丢包由 TCP 重传兜住,画面完整,代价是卡顿与延迟上升。 + factory.setForceUseRtpTcp(PlaybackTuning.forceRtpTcp); + // 0 表示不覆盖,用 Media3 默认超时;调小会加快“无数据→判流结束”,但弱链路易误判断流。 + if (PlaybackTuning.rtspTimeoutMs > 0) { + factory.setTimeoutMs(PlaybackTuning.rtspTimeoutMs); + } + return factory; + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java new file mode 100644 index 00000000..5bcab586 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java @@ -0,0 +1,99 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import java.util.HashMap; +import java.util.Map; + +/** A video to be played by {@link VideoPlayer}. */ +public abstract class VideoAsset { + /** + * Returns an asset from a local {@code asset:///} URL, i.e. an on-device asset. + * + * @param assetUrl local asset, beginning in {@code asset:///}. + * @return the asset. + */ + @NonNull + static VideoAsset fromAssetUrl(@NonNull String assetUrl) { + if (!assetUrl.startsWith("asset:///")) { + throw new IllegalArgumentException("assetUrl must start with 'asset:///'"); + } + return new LocalVideoAsset(assetUrl); + } + + /** + * Returns an asset from a remote URL. + * + * @param remoteUrl remote asset, i.e. typically beginning with {@code https://} or similar. + * @param streamingFormat which streaming format, provided as a hint if able. + * @param httpHeaders HTTP headers to set for a request. + * @return the asset. + */ + @NonNull + static VideoAsset fromRemoteUrl( + @Nullable String remoteUrl, + @NonNull StreamingFormat streamingFormat, + @NonNull Map httpHeaders, + @Nullable String userAgent) { + return new HttpVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders), userAgent); + } + + /** + * Returns an asset from a RTSP URL. + * + * @param rtspUrl remote asset, beginning with {@code rtsp://}. + * @return the asset. + */ + @NonNull + static VideoAsset fromRtspUrl(@NonNull String rtspUrl) { + if (!rtspUrl.startsWith("rtsp://")) { + throw new IllegalArgumentException("rtspUrl must start with 'rtsp://'"); + } + return new RtspVideoAsset(rtspUrl); + } + + @Nullable protected final String assetUrl; + + protected VideoAsset(@Nullable String assetUrl) { + this.assetUrl = assetUrl; + } + + /** + * Returns the configured media item to be played. + * + * @return media item. + */ + @NonNull + public abstract MediaItem getMediaItem(); + + /** + * Returns the configured media source factory, if needed for this asset type. + * + * @param context application context. + * @return configured factory, or {@code null} if not needed for this asset type. + */ + @NonNull + public abstract MediaSource.Factory getMediaSourceFactory(@NonNull Context context); + + /** Streaming formats that can be provided to the video player as a hint. */ + enum StreamingFormat { + /** Default, if the format is either not known or not another valid format. */ + UNKNOWN, + + /** Smooth Streaming. */ + SMOOTH, + + /** MPEG-DASH (Dynamic Adaptive over HTTP). */ + DYNAMIC_ADAPTIVE, + + /** HTTP Live Streaming (HLS). */ + HTTP_LIVE + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java new file mode 100644 index 00000000..7cfb5c1c --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java @@ -0,0 +1,242 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static androidx.media3.common.Player.REPEAT_MODE_ALL; +import static androidx.media3.common.Player.REPEAT_MODE_OFF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; +import androidx.media3.common.Tracks; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import io.flutter.view.TextureRegistry.SurfaceProducer; +import java.util.ArrayList; +import java.util.List; + +/** + * A class responsible for managing video playback using {@link ExoPlayer}. + * + *

It provides methods to control playback, adjust volume, and handle seeking. + */ +public abstract class VideoPlayer implements VideoPlayerInstanceApi { + @NonNull protected final VideoPlayerCallbacks videoPlayerEvents; + @Nullable protected final SurfaceProducer surfaceProducer; + @Nullable private DisposeHandler disposeHandler; + @NonNull protected ExoPlayer exoPlayer; + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi @Nullable protected DefaultTrackSelector trackSelector; + + /** A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. */ + public interface ExoPlayerProvider { + /** + * Returns a new {@link ExoPlayer}. + * + * @return new instance. + */ + @NonNull + ExoPlayer get(); + } + + /** A handler to run when dispose is called. */ + public interface DisposeHandler { + void onDispose(); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + // Error thrown for this-escape warning on JDK 21+ due to https://bugs.openjdk.org/browse/JDK-8015831. + // Keeping behavior as-is and addressing the warning could cause a regression: https://github.com/flutter/packages/pull/10193 + @SuppressWarnings("this-escape") + public VideoPlayer( + @NonNull VideoPlayerCallbacks events, + @NonNull MediaItem mediaItem, + @NonNull VideoPlayerOptions options, + @Nullable SurfaceProducer surfaceProducer, + @NonNull ExoPlayerProvider exoPlayerProvider) { + this.videoPlayerEvents = events; + this.surfaceProducer = surfaceProducer; + exoPlayer = exoPlayerProvider.get(); + + // Try to get the track selector from the ExoPlayer if it was built with one + if (exoPlayer.getTrackSelector() instanceof DefaultTrackSelector) { + trackSelector = (DefaultTrackSelector) exoPlayer.getTrackSelector(); + } + + exoPlayer.setMediaItem(mediaItem); + exoPlayer.prepare(); + exoPlayer.addListener(createExoPlayerEventListener(exoPlayer, surfaceProducer)); + setAudioAttributes(exoPlayer, options.mixWithOthers); + } + + public void setDisposeHandler(@Nullable DisposeHandler handler) { + disposeHandler = handler; + } + + @NonNull + protected abstract ExoPlayerEventListener createExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer); + + private static void setAudioAttributes(ExoPlayer exoPlayer, boolean isMixMode) { + exoPlayer.setAudioAttributes( + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(), + !isMixMode); + } + + @Override + public void play() { + exoPlayer.play(); + } + + @Override + public void pause() { + exoPlayer.pause(); + } + + @Override + public void setLooping(boolean looping) { + exoPlayer.setRepeatMode(looping ? REPEAT_MODE_ALL : REPEAT_MODE_OFF); + } + + @Override + public void setVolume(double volume) { + float bracketedValue = (float) Math.max(0.0, Math.min(1.0, volume)); + exoPlayer.setVolume(bracketedValue); + } + + @Override + public void setPlaybackSpeed(double speed) { + // We do not need to consider pitch and skipSilence for now as we do not handle them and + // therefore never diverge from the default values. + final PlaybackParameters playbackParameters = new PlaybackParameters((float) speed); + + exoPlayer.setPlaybackParameters(playbackParameters); + } + + @Override + public long getCurrentPosition() { + return exoPlayer.getCurrentPosition(); + } + + @Override + public long getBufferedPosition() { + return exoPlayer.getBufferedPosition(); + } + + @Override + public void seekTo(long position) { + exoPlayer.seekTo(position); + } + + @NonNull + public ExoPlayer getExoPlayer() { + return exoPlayer; + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @Override + public @NonNull NativeAudioTrackData getAudioTracks() { + List audioTracks = new ArrayList<>(); + + // Get the current tracks from ExoPlayer + Tracks tracks = exoPlayer.getCurrentTracks(); + + // Iterate through all track groups + for (int groupIndex = 0; groupIndex < tracks.getGroups().size(); groupIndex++) { + Tracks.Group group = tracks.getGroups().get(groupIndex); + + // Only process audio tracks + if (group.getType() == C.TRACK_TYPE_AUDIO) { + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getTrackFormat(trackIndex); + boolean isSelected = group.isTrackSelected(trackIndex); + + // Create audio track data with metadata + ExoPlayerAudioTrackData audioTrack = + new ExoPlayerAudioTrackData( + (long) groupIndex, + (long) trackIndex, + format.label, + format.language, + isSelected, + format.bitrate != Format.NO_VALUE ? (long) format.bitrate : null, + format.sampleRate != Format.NO_VALUE ? (long) format.sampleRate : null, + format.channelCount != Format.NO_VALUE ? (long) format.channelCount : null, + format.codecs != null ? format.codecs : null); + + audioTracks.add(audioTrack); + } + } + } + return new NativeAudioTrackData(audioTracks); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @Override + public void selectAudioTrack(long groupIndex, long trackIndex) { + if (trackSelector == null) { + throw new IllegalStateException("Cannot select audio track: track selector is null"); + } + + // Get current tracks + Tracks tracks = exoPlayer.getCurrentTracks(); + + if (groupIndex < 0 || groupIndex >= tracks.getGroups().size()) { + throw new IllegalArgumentException( + "Cannot select audio track: groupIndex " + + groupIndex + + " is out of bounds (available groups: " + + tracks.getGroups().size() + + ")"); + } + + Tracks.Group group = tracks.getGroups().get((int) groupIndex); + + // Verify it's an audio track + if (group.getType() != C.TRACK_TYPE_AUDIO) { + throw new IllegalArgumentException( + "Cannot select audio track: group at index " + + groupIndex + + " is not an audio track (type: " + + group.getType() + + ")"); + } + + // Verify the track index is valid + if (trackIndex < 0 || (int) trackIndex >= group.length) { + throw new IllegalArgumentException( + "Cannot select audio track: trackIndex " + + trackIndex + + " is out of bounds (available tracks in group: " + + group.length + + ")"); + } + + // Get the track group and create a selection override + TrackGroup trackGroup = group.getMediaTrackGroup(); + TrackSelectionOverride override = new TrackSelectionOverride(trackGroup, (int) trackIndex); + + // Apply the track selection override + trackSelector.setParameters( + trackSelector.buildUponParameters().setOverrideForType(override).build()); + } + + public void dispose() { + if (disposeHandler != null) { + disposeHandler.onDispose(); + } + exoPlayer.release(); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java new file mode 100644 index 00000000..4cac9023 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java @@ -0,0 +1,29 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Callbacks representing events invoked by {@link VideoPlayer}. + * + *

In the actual plugin, this will always be {@link VideoPlayerEventCallbacks}, which creates the + * expected events to send back through the plugin channel. In tests methods can be overridden in + * order to assert results. + * + *

See {@link androidx.media3.common.Player.Listener} for details. + */ +public interface VideoPlayerCallbacks { + void onInitialized(int width, int height, long durationInMs, int rotationCorrectionInDegrees); + + void onPlaybackStateChanged(@NonNull PlatformPlaybackState state); + + void onError(@NonNull String code, @Nullable String message, @Nullable Object details); + + void onIsPlayingStateUpdate(boolean isPlaying); + + void onAudioTrackChanged(@Nullable String selectedTrackId); +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java new file mode 100644 index 00000000..a471ec96 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java @@ -0,0 +1,71 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import io.flutter.plugin.common.BinaryMessenger; + +final class VideoPlayerEventCallbacks implements VideoPlayerCallbacks { + private final QueuingEventSink eventSink; + + static VideoPlayerEventCallbacks bindTo( + @NonNull BinaryMessenger binaryMessenger, @NonNull String identifier) { + QueuingEventSink eventSink = new QueuingEventSink(); + VideoEventsStreamHandler.Companion.register( + binaryMessenger, + new VideoEventsStreamHandler() { + @Override + public void onListen( + Object arguments, @NonNull PigeonEventSink events) { + eventSink.setDelegate(events); + } + + @Override + public void onCancel(Object arguments) { + eventSink.setDelegate(null); + } + }, + identifier); + return VideoPlayerEventCallbacks.withSink(eventSink); + } + + @VisibleForTesting + static VideoPlayerEventCallbacks withSink(QueuingEventSink eventSink) { + return new VideoPlayerEventCallbacks(eventSink); + } + + private VideoPlayerEventCallbacks(QueuingEventSink eventSink) { + this.eventSink = eventSink; + } + + @Override + public void onInitialized( + int width, int height, long durationInMs, int rotationCorrectionInDegrees) { + eventSink.success( + new InitializationEvent(durationInMs, width, height, rotationCorrectionInDegrees)); + } + + @Override + public void onPlaybackStateChanged(@NonNull PlatformPlaybackState state) { + eventSink.success(new PlaybackStateChangeEvent(state)); + } + + @Override + public void onError(@NonNull String code, @Nullable String message, @Nullable Object details) { + eventSink.error(code, message, details); + } + + @Override + public void onIsPlayingStateUpdate(boolean isPlaying) { + eventSink.success(new IsPlayingStateEvent(isPlaying)); + } + + @Override + public void onAudioTrackChanged(@Nullable String selectedTrackId) { + eventSink.success(new AudioTrackChangedEvent(selectedTrackId)); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerOptions.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerOptions.java new file mode 100644 index 00000000..20f7c5d2 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerOptions.java @@ -0,0 +1,9 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +public class VideoPlayerOptions { + public boolean mixWithOthers; +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java new file mode 100644 index 00000000..71483673 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java @@ -0,0 +1,234 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import android.util.LongSparseArray; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import io.flutter.FlutterInjector; +import io.flutter.Log; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugins.videoplayer.platformview.PlatformVideoViewFactory; +import io.flutter.plugins.videoplayer.platformview.PlatformViewVideoPlayer; +import io.flutter.plugins.videoplayer.texture.TextureVideoPlayer; +import io.flutter.view.TextureRegistry; + +/** Android platform implementation of the VideoPlayerPlugin. */ +public class VideoPlayerPlugin implements FlutterPlugin, AndroidVideoPlayerApi { + private static final String TAG = "VideoPlayerPlugin"; + private final LongSparseArray videoPlayers = new LongSparseArray<>(); + private FlutterState flutterState; + private final VideoPlayerOptions sharedOptions = new VideoPlayerOptions(); + private long nextPlayerIdentifier = 1; + + /** Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. */ + public VideoPlayerPlugin() {} + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + final FlutterInjector injector = FlutterInjector.instance(); + this.flutterState = + new FlutterState( + binding.getApplicationContext(), + binding.getBinaryMessenger(), + injector.flutterLoader()::getLookupKeyForAsset, + injector.flutterLoader()::getLookupKeyForAsset, + binding.getTextureRegistry()); + flutterState.startListening(this, binding.getBinaryMessenger()); + // 本项目本地覆盖新增:注册直播调参通道(RTSP 传输方式 / LoadControl 水位),供 App 现场 A/B。 + PlaybackTuning.setUp(binding.getBinaryMessenger()); + + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.dev/video_player_android", + new PlatformVideoViewFactory(videoPlayers::get)); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (flutterState == null) { + Log.wtf(TAG, "Detached from the engine before registering to it."); + } + flutterState.stopListening(binding.getBinaryMessenger()); + PlaybackTuning.tearDown(binding.getBinaryMessenger()); + flutterState = null; + onDestroy(); + } + + private void disposeAllPlayers() { + for (int i = 0; i < videoPlayers.size(); i++) { + videoPlayers.valueAt(i).dispose(); + } + videoPlayers.clear(); + } + + public void onDestroy() { + // The whole FlutterView is being destroyed. Here we release resources acquired for all + // instances + // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may + // be replaced with just asserting that videoPlayers.isEmpty(). + // https://github.com/flutter/flutter/issues/20989 tracks this. + disposeAllPlayers(); + } + + @Override + public void initialize() { + disposeAllPlayers(); + } + + @OptIn(markerClass = UnstableApi.class) + @Override + public long createForPlatformView(@NonNull CreationOptions options) { + final VideoAsset videoAsset = videoAssetWithOptions(options); + + long id = nextPlayerIdentifier++; + final String streamInstance = Long.toString(id); + VideoPlayer videoPlayer = + PlatformViewVideoPlayer.create( + flutterState.applicationContext, + VideoPlayerEventCallbacks.bindTo(flutterState.binaryMessenger, streamInstance), + videoAsset, + sharedOptions); + + registerPlayerInstance(videoPlayer, id); + return id; + } + + @OptIn(markerClass = UnstableApi.class) + @Override + public @NonNull TexturePlayerIds createForTextureView(@NonNull CreationOptions options) { + final VideoAsset videoAsset = videoAssetWithOptions(options); + + long id = nextPlayerIdentifier++; + final String streamInstance = Long.toString(id); + TextureRegistry.SurfaceProducer handle = flutterState.textureRegistry.createSurfaceProducer(); + VideoPlayer videoPlayer = + TextureVideoPlayer.create( + flutterState.applicationContext, + VideoPlayerEventCallbacks.bindTo(flutterState.binaryMessenger, streamInstance), + handle, + videoAsset, + sharedOptions); + + registerPlayerInstance(videoPlayer, id); + return new TexturePlayerIds(id, handle.id()); + } + + private @NonNull VideoAsset videoAssetWithOptions(@NonNull CreationOptions options) { + final @NonNull String uri = options.getUri(); + if (uri.startsWith("asset:")) { + return VideoAsset.fromAssetUrl(uri); + } else if (uri.startsWith("rtsp:")) { + return VideoAsset.fromRtspUrl(uri); + } else { + VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN; + PlatformVideoFormat formatHint = options.getFormatHint(); + if (formatHint != null) { + switch (formatHint) { + case SS: + streamingFormat = VideoAsset.StreamingFormat.SMOOTH; + break; + case DASH: + streamingFormat = VideoAsset.StreamingFormat.DYNAMIC_ADAPTIVE; + break; + case HLS: + streamingFormat = VideoAsset.StreamingFormat.HTTP_LIVE; + break; + } + } + return VideoAsset.fromRemoteUrl( + uri, streamingFormat, options.getHttpHeaders(), options.getUserAgent()); + } + } + + private void registerPlayerInstance(VideoPlayer player, long id) { + // Set up the instance-specific API handler, and make sure it is removed when the player is + // disposed. + BinaryMessenger messenger = flutterState.binaryMessenger; + final String channelSuffix = Long.toString(id); + VideoPlayerInstanceApi.Companion.setUp(messenger, player, channelSuffix); + player.setDisposeHandler( + () -> VideoPlayerInstanceApi.Companion.setUp(messenger, null, channelSuffix)); + + videoPlayers.put(id, player); + } + + @NonNull + private VideoPlayer getPlayer(long playerId) { + VideoPlayer player = videoPlayers.get(playerId); + + // Avoid a very ugly un-debuggable NPE that results in returning a null player. + if (player == null) { + String message = "No player found with playerId <" + playerId + ">"; + if (videoPlayers.size() == 0) { + message += " and no active players created by the plugin."; + } + throw new IllegalStateException(message); + } + + return player; + } + + @Override + public void dispose(long playerId) { + VideoPlayer player = getPlayer(playerId); + player.dispose(); + videoPlayers.remove(playerId); + } + + @Override + public void setMixWithOthers(boolean mixWithOthers) { + sharedOptions.mixWithOthers = mixWithOthers; + } + + @Override + public @NonNull String getLookupKeyForAsset(@NonNull String asset, @Nullable String packageName) { + return packageName == null + ? flutterState.keyForAsset.get(asset) + : flutterState.keyForAssetAndPackageName.get(asset, packageName); + } + + private interface KeyForAssetFn { + String get(String asset); + } + + private interface KeyForAssetAndPackageName { + String get(String asset, String packageName); + } + + private static final class FlutterState { + final Context applicationContext; + final BinaryMessenger binaryMessenger; + final KeyForAssetFn keyForAsset; + final KeyForAssetAndPackageName keyForAssetAndPackageName; + final TextureRegistry textureRegistry; + + FlutterState( + Context applicationContext, + BinaryMessenger messenger, + KeyForAssetFn keyForAsset, + KeyForAssetAndPackageName keyForAssetAndPackageName, + TextureRegistry textureRegistry) { + this.applicationContext = applicationContext; + this.binaryMessenger = messenger; + this.keyForAsset = keyForAsset; + this.keyForAssetAndPackageName = keyForAssetAndPackageName; + this.textureRegistry = textureRegistry; + } + + void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) { + AndroidVideoPlayerApi.Companion.setUp(messenger, methodCallHandler); + } + + void stopListening(BinaryMessenger messenger) { + AndroidVideoPlayerApi.Companion.setUp(messenger, null); + } + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java new file mode 100644 index 00000000..35fd6628 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java @@ -0,0 +1,94 @@ +// 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. + +package io.flutter.plugins.videoplayer.platformview; + +import android.content.Context; +import android.os.Build; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugin.platform.PlatformView; + +/** + * A class used to create a native video view that can be embedded in a Flutter app. It wraps an + * {@link ExoPlayer} instance and displays its video content. + */ +public final class PlatformVideoView implements PlatformView { + @NonNull private final SurfaceView surfaceView; + + /** + * Constructs a new PlatformVideoView. + * + * @param context The context in which the view is running. + * @param exoPlayer The ExoPlayer instance used to play the video. + */ + @OptIn(markerClass = UnstableApi.class) + public PlatformVideoView(@NonNull Context context, @NonNull ExoPlayer exoPlayer) { + surfaceView = new SurfaceView(context); + + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + // Workaround for rendering issues on Android 9 (API 28). + // On Android 9, using setVideoSurfaceView seems to lead to issues where the first frame is + // not displayed if the video is paused initially. + // To ensure the first frame is visible, the surface is directly set using holder.getSurface() + // when the surface is created, and ExoPlayer seeks to a position to force rendering of the + // first frame. + setupSurfaceWithCallback(exoPlayer); + } else { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { + // Avoid blank space instead of a video on Android versions below 8 by adjusting video's + // z-layer within the Android view hierarchy: + surfaceView.setZOrderMediaOverlay(true); + } + exoPlayer.setVideoSurfaceView(surfaceView); + } + } + + private void setupSurfaceWithCallback(@NonNull ExoPlayer exoPlayer) { + surfaceView + .getHolder() + .addCallback( + new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(@NonNull SurfaceHolder holder) { + exoPlayer.setVideoSurface(holder.getSurface()); + // Force first frame rendering: + exoPlayer.seekTo(1); + } + + @Override + public void surfaceChanged( + @NonNull SurfaceHolder holder, int format, int width, int height) { + // No implementation needed. + } + + @Override + public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + exoPlayer.setVideoSurface(null); + } + }); + } + + /** + * Returns the view associated with this PlatformView. + * + * @return The SurfaceView used to display the video. + */ + @NonNull + @Override + public View getView() { + return surfaceView; + } + + /** Disposes of the resources used by this PlatformView. */ + @Override + public void dispose() { + surfaceView.getHolder().getSurface().release(); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoViewFactory.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoViewFactory.java new file mode 100644 index 00000000..19434db5 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoViewFactory.java @@ -0,0 +1,69 @@ +// 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. + +package io.flutter.plugins.videoplayer.platformview; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; +import io.flutter.plugins.videoplayer.AndroidVideoPlayerApi; +import io.flutter.plugins.videoplayer.PlatformVideoViewCreationParams; +import io.flutter.plugins.videoplayer.VideoPlayer; +import java.util.Objects; + +/** + * A factory class responsible for creating platform video views that can be embedded in a Flutter + * app. + */ +public class PlatformVideoViewFactory extends PlatformViewFactory { + private final VideoPlayerProvider videoPlayerProvider; + + /** Functional interface for providing a VideoPlayer instance based on the player ID. */ + @FunctionalInterface + public interface VideoPlayerProvider { + /** + * Retrieves a VideoPlayer instance based on the provided player ID. + * + * @param playerId The unique identifier for the video player. + * @return A VideoPlayer instance associated with the given player ID. + */ + @NonNull + VideoPlayer getVideoPlayer(@NonNull Long playerId); + } + + /** + * Constructs a new PlatformVideoViewFactory. + * + * @param videoPlayerProvider The provider used to retrieve the video player associated with the + * view. + */ + public PlatformVideoViewFactory(@NonNull VideoPlayerProvider videoPlayerProvider) { + super(AndroidVideoPlayerApi.Companion.getCodec()); + this.videoPlayerProvider = videoPlayerProvider; + } + + /** + * Creates a new instance of platform view. + * + * @param context The context in which the view is running. + * @param id The unique identifier for the view. + * @param args The arguments for creating the view. + * @return A new instance of PlatformVideoView. + */ + @NonNull + @Override + public PlatformView create(@NonNull Context context, int id, @Nullable Object args) { + final PlatformVideoViewCreationParams params = + Objects.requireNonNull((PlatformVideoViewCreationParams) args); + final Long playerId = params.getPlayerId(); + + final VideoPlayer player = videoPlayerProvider.getVideoPlayer(playerId); + final ExoPlayer exoPlayer = player.getExoPlayer(); + + return new PlatformVideoView(context, exoPlayer); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java new file mode 100644 index 00000000..699eab6e --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java @@ -0,0 +1,45 @@ +// 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. + +package io.flutter.plugins.videoplayer.platformview; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.media3.common.Format; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.ExoPlayerEventListener; +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks; +import java.util.Objects; + +public final class PlatformViewExoPlayerEventListener extends ExoPlayerEventListener { + public PlatformViewExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events) { + super(exoPlayer, events); + } + + @OptIn(markerClass = UnstableApi.class) + @Override + protected void sendInitialized() { + // We can't rely on VideoSize here, because at this point it is not available - the platform + // view was not created yet. We use the video format instead. + Format videoFormat = exoPlayer.getVideoFormat(); + RotationDegrees rotationCorrection = + RotationDegrees.fromDegrees(Objects.requireNonNull(videoFormat).rotationDegrees); + int width = videoFormat.width; + int height = videoFormat.height; + + // Switch the width/height if video was taken in portrait mode and a rotation + // correction was detected. + if (rotationCorrection == RotationDegrees.ROTATE_90 + || rotationCorrection == RotationDegrees.ROTATE_270) { + width = videoFormat.height; + height = videoFormat.width; + + rotationCorrection = RotationDegrees.fromDegrees(0); + } + + events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees()); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java new file mode 100644 index 00000000..d03a6314 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java @@ -0,0 +1,95 @@ +// 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. + +package io.flutter.plugins.videoplayer.platformview; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.ExoPlayerEventListener; +import io.flutter.plugins.videoplayer.VideoAsset; +import io.flutter.plugins.videoplayer.VideoPlayer; +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks; +import io.flutter.plugins.videoplayer.VideoPlayerOptions; +import io.flutter.view.TextureRegistry.SurfaceProducer; + +/** + * A subclass of {@link VideoPlayer} that adds functionality related to platform view as a way of + * displaying the video in the app. + */ +public class PlatformViewVideoPlayer extends VideoPlayer { + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @VisibleForTesting + public PlatformViewVideoPlayer( + @NonNull VideoPlayerCallbacks events, + @NonNull MediaItem mediaItem, + @NonNull VideoPlayerOptions options, + @NonNull ExoPlayerProvider exoPlayerProvider) { + super(events, mediaItem, options, /* surfaceProducer */ null, exoPlayerProvider); + } + + /** + * Creates a platform view video player. + * + * @param context application context. + * @param events event callbacks. + * @param asset asset to play. + * @param options options for playback. + * @return a video player instance. + */ + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @NonNull + public static PlatformViewVideoPlayer create( + @NonNull Context context, + @NonNull VideoPlayerCallbacks events, + @NonNull VideoAsset asset, + @NonNull VideoPlayerOptions options) { + return new PlatformViewVideoPlayer( + events, + asset.getMediaItem(), + options, + () -> { + androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector = + new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context); + // 直播低延迟:四个水位参数由 App 侧通过 PlaybackTuning 下发(默认 500/3000/0/0)。 + // 与 TextureVideoPlayer 保持一致:maxBufferMs 是稳态积压上限, + // 平均积压约为 (minBufferMs + maxBufferMs) / 2;bufferForPlaybackMs=0 保证首帧即渲染。 + DefaultLoadControl loadControl = + new DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs= */ io.flutter.plugins.videoplayer.PlaybackTuning.minBufferMs, + /* maxBufferMs= */ io.flutter.plugins.videoplayer.PlaybackTuning.maxBufferMs, + /* bufferForPlaybackMs= */ + io.flutter.plugins.videoplayer.PlaybackTuning.bufferForPlaybackMs, + /* bufferForPlaybackAfterRebufferMs= */ + io.flutter.plugins.videoplayer.PlaybackTuning.bufferForPlaybackAfterRebufferMs) + .setTargetBufferBytes( + io.flutter.plugins.videoplayer.PlaybackTuning.targetBufferBytes) + .build(); + ExoPlayer.Builder builder = + new ExoPlayer.Builder(context) + .setTrackSelector(trackSelector) + // 与 TextureVideoPlayer 一致:解码方式运行时可调。 + .setRenderersFactory( + io.flutter.plugins.videoplayer.PlaybackTuning.buildRenderersFactory(context)) + .setMediaSourceFactory(asset.getMediaSourceFactory(context)) + .setLoadControl(loadControl); + return builder.build(); + }); + } + + @NonNull + @Override + protected ExoPlayerEventListener createExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer) { + return new PlatformViewExoPlayerEventListener(exoPlayer, videoPlayerEvents); + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureExoPlayerEventListener.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureExoPlayerEventListener.java new file mode 100644 index 00000000..bcc901b7 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureExoPlayerEventListener.java @@ -0,0 +1,62 @@ +// 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. + +package io.flutter.plugins.videoplayer.texture; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.media3.common.Format; +import androidx.media3.common.VideoSize; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.ExoPlayerEventListener; +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks; +import java.util.Objects; + +public final class TextureExoPlayerEventListener extends ExoPlayerEventListener { + private final boolean surfaceProducerHandlesCropAndRotation; + + public TextureExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, + @NonNull VideoPlayerCallbacks events, + boolean surfaceProducerHandlesCropAndRotation) { + super(exoPlayer, events); + this.surfaceProducerHandlesCropAndRotation = surfaceProducerHandlesCropAndRotation; + } + + @Override + protected void sendInitialized() { + VideoSize videoSize = exoPlayer.getVideoSize(); + RotationDegrees rotationCorrection = RotationDegrees.ROTATE_0; + int width = videoSize.width; + int height = videoSize.height; + if (width != 0 && height != 0) { + // When the SurfaceTexture backend for Impeller is used, the preview should already + // be correctly rotated. + if (!surfaceProducerHandlesCropAndRotation) { + // The video's Format also provides a rotation correction that may be used to + // correct the rotation, so we try to use that to correct the video rotation + // when the ImageReader backend for Impeller is used. + int rawVideoFormatRotation = getRotationCorrectionFromFormat(exoPlayer); + + try { + rotationCorrection = RotationDegrees.fromDegrees(rawVideoFormatRotation); + } catch (IllegalArgumentException e) { + // Rotation correction other than 0, 90, 180, 270 reported by Format. Because this is + // unexpected we apply no rotation correction. + rotationCorrection = RotationDegrees.ROTATE_0; + } + } + } + events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees()); + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + // A video's Format and its rotation degrees are unstable because they are not guaranteed + // the same implementation across API versions. It is possible that this logic may need + // revisiting should the implementation change across versions of the Exoplayer API. + private int getRotationCorrectionFromFormat(ExoPlayer exoPlayer) { + Format videoFormat = Objects.requireNonNull(exoPlayer.getVideoFormat()); + return videoFormat.rotationDegrees; + } +} diff --git a/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java new file mode 100644 index 00000000..d7a49674 --- /dev/null +++ b/packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java @@ -0,0 +1,148 @@ +// 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. + +package io.flutter.plugins.videoplayer.texture; + +import android.content.Context; +import android.view.Surface; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.ExoPlayerEventListener; +import io.flutter.plugins.videoplayer.VideoAsset; +import io.flutter.plugins.videoplayer.VideoPlayer; +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks; +import io.flutter.plugins.videoplayer.VideoPlayerOptions; +import io.flutter.view.TextureRegistry.SurfaceProducer; + +/** + * A subclass of {@link VideoPlayer} that adds functionality related to texture view as a way of + * displaying the video in the app. + * + *

It manages the lifecycle of the texture and ensures that the video is properly displayed on + * the texture. + */ +public final class TextureVideoPlayer extends VideoPlayer implements SurfaceProducer.Callback { + // True when the ExoPlayer instance has a null surface. + private boolean needsSurface = true; + /** + * Creates a texture video player. + * + * @param context application context. + * @param events event callbacks. + * @param surfaceProducer produces a texture to render to. + * @param asset asset to play. + * @param options options for playback. + * @return a video player instance. + */ + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @NonNull + public static TextureVideoPlayer create( + @NonNull Context context, + @NonNull VideoPlayerCallbacks events, + @NonNull SurfaceProducer surfaceProducer, + @NonNull VideoAsset asset, + @NonNull VideoPlayerOptions options) { + return new TextureVideoPlayer( + events, + surfaceProducer, + asset.getMediaItem(), + options, + () -> { + androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector = + new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context); + // 直播低延迟:四个水位参数由 App 侧通过 PlaybackTuning 下发(默认 500/3000/0/0)。 + // 依据 Media3 DefaultLoadControl 源码: + // - minBufferUs 有 500ms 硬下限,填更小不会生效; + // - Loader 在 minBufferMs..maxBufferMs 之间锯齿,maxBufferMs 是稳态积压上限, + // 平均积压约为 (minBufferMs + maxBufferMs) / 2; + // - bufferForPlaybackMs=0 才是“首帧到达即渲染”的关键。 + // - targetBufferBytes 是另一条停止加载的条件(字节上限),0 = 用 Media3 默认; + // 主码流/高码率时默认约 2MB 会先于时间水位撞上,需调大。 + DefaultLoadControl loadControl = + new DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs= */ io.flutter.plugins.videoplayer.PlaybackTuning.minBufferMs, + /* maxBufferMs= */ io.flutter.plugins.videoplayer.PlaybackTuning.maxBufferMs, + /* bufferForPlaybackMs= */ + io.flutter.plugins.videoplayer.PlaybackTuning.bufferForPlaybackMs, + /* bufferForPlaybackAfterRebufferMs= */ + io.flutter.plugins.videoplayer.PlaybackTuning.bufferForPlaybackAfterRebufferMs) + .setTargetBufferBytes( + io.flutter.plugins.videoplayer.PlaybackTuning.targetBufferBytes) + .build(); + ExoPlayer.Builder builder = + new ExoPlayer.Builder(context) + .setTrackSelector(trackSelector) + // 解码方式(硬解/软解/失败回退)也改成运行时可调,默认保持 Media3 原生行为。 + .setRenderersFactory( + io.flutter.plugins.videoplayer.PlaybackTuning.buildRenderersFactory(context)) + .setMediaSourceFactory(asset.getMediaSourceFactory(context)) + .setLoadControl(loadControl); + return builder.build(); + }); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @VisibleForTesting + public TextureVideoPlayer( + @NonNull VideoPlayerCallbacks events, + @NonNull SurfaceProducer surfaceProducer, + @NonNull MediaItem mediaItem, + @NonNull VideoPlayerOptions options, + @NonNull ExoPlayerProvider exoPlayerProvider) { + super(events, mediaItem, options, surfaceProducer, exoPlayerProvider); + + surfaceProducer.setCallback(this); + + Surface surface = surfaceProducer.getSurface(); + this.exoPlayer.setVideoSurface(surface); + needsSurface = surface == null; + } + + @NonNull + @Override + protected ExoPlayerEventListener createExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer) { + if (surfaceProducer == null) { + throw new IllegalArgumentException( + "surfaceProducer cannot be null to create an ExoPlayerEventListener for TextureVideoPlayer."); + } + boolean surfaceProducerHandlesCropAndRotation = surfaceProducer.handlesCropAndRotation(); + return new TextureExoPlayerEventListener( + exoPlayer, videoPlayerEvents, surfaceProducerHandlesCropAndRotation); + } + + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void onSurfaceAvailable() { + if (needsSurface) { + // TextureVideoPlayer must always set a surfaceProducer. + assert surfaceProducer != null; + exoPlayer.setVideoSurface(surfaceProducer.getSurface()); + needsSurface = false; + } + } + + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void onSurfaceCleanup() { + exoPlayer.setVideoSurface(null); + needsSurface = true; + } + + public void dispose() { + // Super must be called first to ensure the player is released before the surface. + super.dispose(); + + // TextureVideoPlayer must always set a surfaceProducer. + assert surfaceProducer != null; + surfaceProducer.release(); + } +} diff --git a/packages/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt b/packages/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt new file mode 100644 index 00000000..e546c744 --- /dev/null +++ b/packages/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt @@ -0,0 +1,1151 @@ +// 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 +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package io.flutter.plugins.videoplayer + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private object MessagesPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf(exception.code, exception.message, exception.details) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)) + } + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && a.indices.all { deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && a.indices.all { deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && + a.all { (b as Map).contains(it.key) && deepEquals(it.value, b[it.key]) } + } + return a == b + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Pigeon equivalent of video_platform_interface's VideoFormat. */ +enum class PlatformVideoFormat(val raw: Int) { + DASH(0), + HLS(1), + SS(2); + + companion object { + fun ofRaw(raw: Int): PlatformVideoFormat? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Pigeon equivalent of Player's playback state. + * https://developer.android.com/media/media3/exoplayer/listening-to-player-events#playback-state + */ +enum class PlatformPlaybackState(val raw: Int) { + IDLE(0), + BUFFERING(1), + READY(2), + ENDED(3), + UNKNOWN(4); + + companion object { + fun ofRaw(raw: Int): PlatformPlaybackState? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Generated class from Pigeon that represents data sent in messages. This class should not be + * extended by any user class outside of the generated file. + */ +sealed class PlatformVideoEvent +/** + * Sent when the video is initialized and ready to play. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class InitializationEvent( + /** The video duration in milliseconds. */ + val duration: Long, + /** The width of the video in pixels. */ + val width: Long, + /** The height of the video in pixels. */ + val height: Long, + /** The rotation that should be applied during playback. */ + val rotationCorrection: Long +) : PlatformVideoEvent() { + companion object { + fun fromList(pigeonVar_list: List): InitializationEvent { + val duration = pigeonVar_list[0] as Long + val width = pigeonVar_list[1] as Long + val height = pigeonVar_list[2] as Long + val rotationCorrection = pigeonVar_list[3] as Long + return InitializationEvent(duration, width, height, rotationCorrection) + } + } + + fun toList(): List { + return listOf( + duration, + width, + height, + rotationCorrection, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is InitializationEvent) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Sent when the video state changes. + * + * Corresponds to ExoPlayer's onPlaybackStateChanged. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlaybackStateChangeEvent(val state: PlatformPlaybackState) : PlatformVideoEvent() { + companion object { + fun fromList(pigeonVar_list: List): PlaybackStateChangeEvent { + val state = pigeonVar_list[0] as PlatformPlaybackState + return PlaybackStateChangeEvent(state) + } + } + + fun toList(): List { + return listOf( + state, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is PlaybackStateChangeEvent) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Sent when the video starts or stops playing. + * + * Corresponds to ExoPlayer's onIsPlayingChanged. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IsPlayingStateEvent(val isPlaying: Boolean) : PlatformVideoEvent() { + companion object { + fun fromList(pigeonVar_list: List): IsPlayingStateEvent { + val isPlaying = pigeonVar_list[0] as Boolean + return IsPlayingStateEvent(isPlaying) + } + } + + fun toList(): List { + return listOf( + isPlaying, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is IsPlayingStateEvent) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Sent when audio tracks change. + * + * This includes when the selected audio track changes after calling selectAudioTrack. Corresponds + * to ExoPlayer's onTracksChanged. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AudioTrackChangedEvent( + /** The ID of the newly selected audio track, if any. */ + val selectedTrackId: String? = null +) : PlatformVideoEvent() { + companion object { + fun fromList(pigeonVar_list: List): AudioTrackChangedEvent { + val selectedTrackId = pigeonVar_list[0] as String? + return AudioTrackChangedEvent(selectedTrackId) + } + } + + fun toList(): List { + return listOf( + selectedTrackId, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is AudioTrackChangedEvent) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Information passed to the platform view creation. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlatformVideoViewCreationParams(val playerId: Long) { + companion object { + fun fromList(pigeonVar_list: List): PlatformVideoViewCreationParams { + val playerId = pigeonVar_list[0] as Long + return PlatformVideoViewCreationParams(playerId) + } + } + + fun toList(): List { + return listOf( + playerId, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is PlatformVideoViewCreationParams) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class CreationOptions( + val uri: String, + val formatHint: PlatformVideoFormat? = null, + val httpHeaders: Map, + val userAgent: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): CreationOptions { + val uri = pigeonVar_list[0] as String + val formatHint = pigeonVar_list[1] as PlatformVideoFormat? + val httpHeaders = pigeonVar_list[2] as Map + val userAgent = pigeonVar_list[3] as String? + return CreationOptions(uri, formatHint, httpHeaders, userAgent) + } + } + + fun toList(): List { + return listOf( + uri, + formatHint, + httpHeaders, + userAgent, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is CreationOptions) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class TexturePlayerIds(val playerId: Long, val textureId: Long) { + companion object { + fun fromList(pigeonVar_list: List): TexturePlayerIds { + val playerId = pigeonVar_list[0] as Long + val textureId = pigeonVar_list[1] as Long + return TexturePlayerIds(playerId, textureId) + } + } + + fun toList(): List { + return listOf( + playerId, + textureId, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is TexturePlayerIds) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PlaybackState( + /** The current playback position, in milliseconds. */ + val playPosition: Long, + /** The current buffer position, in milliseconds. */ + val bufferPosition: Long +) { + companion object { + fun fromList(pigeonVar_list: List): PlaybackState { + val playPosition = pigeonVar_list[0] as Long + val bufferPosition = pigeonVar_list[1] as Long + return PlaybackState(playPosition, bufferPosition) + } + } + + fun toList(): List { + return listOf( + playPosition, + bufferPosition, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is PlaybackState) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents an audio track in a video. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AudioTrackMessage( + val id: String, + val label: String, + val language: String, + val isSelected: Boolean, + val bitrate: Long? = null, + val sampleRate: Long? = null, + val channelCount: Long? = null, + val codec: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): AudioTrackMessage { + val id = pigeonVar_list[0] as String + val label = pigeonVar_list[1] as String + val language = pigeonVar_list[2] as String + val isSelected = pigeonVar_list[3] as Boolean + val bitrate = pigeonVar_list[4] as Long? + val sampleRate = pigeonVar_list[5] as Long? + val channelCount = pigeonVar_list[6] as Long? + val codec = pigeonVar_list[7] as String? + return AudioTrackMessage( + id, label, language, isSelected, bitrate, sampleRate, channelCount, codec) + } + } + + fun toList(): List { + return listOf( + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is AudioTrackMessage) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Raw audio track data from ExoPlayer Format objects. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ExoPlayerAudioTrackData( + val groupIndex: Long, + val trackIndex: Long, + val label: String? = null, + val language: String? = null, + val isSelected: Boolean, + val bitrate: Long? = null, + val sampleRate: Long? = null, + val channelCount: Long? = null, + val codec: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): ExoPlayerAudioTrackData { + val groupIndex = pigeonVar_list[0] as Long + val trackIndex = pigeonVar_list[1] as Long + val label = pigeonVar_list[2] as String? + val language = pigeonVar_list[3] as String? + val isSelected = pigeonVar_list[4] as Boolean + val bitrate = pigeonVar_list[5] as Long? + val sampleRate = pigeonVar_list[6] as Long? + val channelCount = pigeonVar_list[7] as Long? + val codec = pigeonVar_list[8] as String? + return ExoPlayerAudioTrackData( + groupIndex, + trackIndex, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec) + } + } + + fun toList(): List { + return listOf( + groupIndex, + trackIndex, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is ExoPlayerAudioTrackData) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Container for raw audio track data from Android ExoPlayer. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NativeAudioTrackData( + /** ExoPlayer-based tracks */ + val exoPlayerTracks: List? = null +) { + companion object { + fun fromList(pigeonVar_list: List): NativeAudioTrackData { + val exoPlayerTracks = pigeonVar_list[0] as List? + return NativeAudioTrackData(exoPlayerTracks) + } + } + + fun toList(): List { + return listOf( + exoPlayerTracks, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is NativeAudioTrackData) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +private open class MessagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { PlatformVideoFormat.ofRaw(it.toInt()) } + } + 130.toByte() -> { + return (readValue(buffer) as Long?)?.let { PlatformPlaybackState.ofRaw(it.toInt()) } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { InitializationEvent.fromList(it) } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { PlaybackStateChangeEvent.fromList(it) } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { IsPlayingStateEvent.fromList(it) } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { AudioTrackChangedEvent.fromList(it) } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlatformVideoViewCreationParams.fromList(it) + } + } + 136.toByte() -> { + return (readValue(buffer) as? List)?.let { CreationOptions.fromList(it) } + } + 137.toByte() -> { + return (readValue(buffer) as? List)?.let { TexturePlayerIds.fromList(it) } + } + 138.toByte() -> { + return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) } + } + 139.toByte() -> { + return (readValue(buffer) as? List)?.let { AudioTrackMessage.fromList(it) } + } + 140.toByte() -> { + return (readValue(buffer) as? List)?.let { ExoPlayerAudioTrackData.fromList(it) } + } + 141.toByte() -> { + return (readValue(buffer) as? List)?.let { NativeAudioTrackData.fromList(it) } + } + else -> super.readValueOfType(type, buffer) + } + } + + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is PlatformVideoFormat -> { + stream.write(129) + writeValue(stream, value.raw.toLong()) + } + is PlatformPlaybackState -> { + stream.write(130) + writeValue(stream, value.raw.toLong()) + } + is InitializationEvent -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is PlaybackStateChangeEvent -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is IsPlayingStateEvent -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is AudioTrackChangedEvent -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is PlatformVideoViewCreationParams -> { + stream.write(135) + writeValue(stream, value.toList()) + } + is CreationOptions -> { + stream.write(136) + writeValue(stream, value.toList()) + } + is TexturePlayerIds -> { + stream.write(137) + writeValue(stream, value.toList()) + } + is PlaybackState -> { + stream.write(138) + writeValue(stream, value.toList()) + } + is AudioTrackMessage -> { + stream.write(139) + writeValue(stream, value.toList()) + } + is ExoPlayerAudioTrackData -> { + stream.write(140) + writeValue(stream, value.toList()) + } + is NativeAudioTrackData -> { + stream.write(141) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +val MessagesPigeonMethodCodec = StandardMethodCodec(MessagesPigeonCodec()) + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface AndroidVideoPlayerApi { + fun initialize() + + fun createForPlatformView(options: CreationOptions): Long + + fun createForTextureView(options: CreationOptions): TexturePlayerIds + + fun dispose(playerId: Long) + + fun setMixWithOthers(mixWithOthers: Boolean) + + fun getLookupKeyForAsset(asset: String, packageName: String?): String + + companion object { + /** The codec used by AndroidVideoPlayerApi. */ + val codec: MessageCodec by lazy { MessagesPigeonCodec() } + /** + * Sets up an instance of `AndroidVideoPlayerApi` to handle messages through the + * `binaryMessenger`. + */ + @JvmOverloads + fun setUp( + binaryMessenger: BinaryMessenger, + api: AndroidVideoPlayerApi?, + messageChannelSuffix: String = "" + ) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.initialize$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + api.initialize() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.createForPlatformView$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsArg = args[0] as CreationOptions + val wrapped: List = + try { + listOf(api.createForPlatformView(optionsArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.createForTextureView$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsArg = args[0] as CreationOptions + val wrapped: List = + try { + listOf(api.createForTextureView(optionsArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.dispose$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val playerIdArg = args[0] as Long + val wrapped: List = + try { + api.dispose(playerIdArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setMixWithOthers$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val mixWithOthersArg = args[0] as Boolean + val wrapped: List = + try { + api.setMixWithOthers(mixWithOthersArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.getLookupKeyForAsset$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val assetArg = args[0] as String + val packageNameArg = args[1] as String? + val wrapped: List = + try { + listOf(api.getLookupKeyForAsset(assetArg, packageNameArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface VideoPlayerInstanceApi { + /** Sets whether to automatically loop playback of the video. */ + fun setLooping(looping: Boolean) + /** Sets the volume, with 0.0 being muted and 1.0 being full volume. */ + fun setVolume(volume: Double) + /** Sets the playback speed as a multiple of normal speed. */ + fun setPlaybackSpeed(speed: Double) + /** Begins playback if the video is not currently playing. */ + fun play() + /** Pauses playback if the video is currently playing. */ + fun pause() + /** Seeks to the given playback position, in milliseconds. */ + fun seekTo(position: Long) + /** Returns the current playback position, in milliseconds. */ + fun getCurrentPosition(): Long + /** Returns the current buffer position, in milliseconds. */ + fun getBufferedPosition(): Long + /** Gets the available audio tracks for the video. */ + fun getAudioTracks(): NativeAudioTrackData + /** Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] */ + fun selectAudioTrack(groupIndex: Long, trackIndex: Long) + + companion object { + /** The codec used by VideoPlayerInstanceApi. */ + val codec: MessageCodec by lazy { MessagesPigeonCodec() } + /** + * Sets up an instance of `VideoPlayerInstanceApi` to handle messages through the + * `binaryMessenger`. + */ + @JvmOverloads + fun setUp( + binaryMessenger: BinaryMessenger, + api: VideoPlayerInstanceApi?, + messageChannelSuffix: String = "" + ) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setLooping$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val loopingArg = args[0] as Boolean + val wrapped: List = + try { + api.setLooping(loopingArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setVolume$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val volumeArg = args[0] as Double + val wrapped: List = + try { + api.setVolume(volumeArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.setPlaybackSpeed$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val speedArg = args[0] as Double + val wrapped: List = + try { + api.setPlaybackSpeed(speedArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.play$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + api.play() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.pause$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + api.pause() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.seekTo$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val positionArg = args[0] as Long + val wrapped: List = + try { + api.seekTo(positionArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getCurrentPosition$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + listOf(api.getCurrentPosition()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getBufferedPosition$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + listOf(api.getBufferedPosition()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + listOf(api.getAudioTracks()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectAudioTrack$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val groupIndexArg = args[0] as Long + val trackIndexArg = args[1] as Long + val wrapped: List = + try { + api.selectAudioTrack(groupIndexArg, trackIndexArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} + +private class MessagesPigeonStreamHandler(val wrapper: MessagesPigeonEventChannelWrapper) : + EventChannel.StreamHandler { + var pigeonSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: EventChannel.EventSink) { + pigeonSink = PigeonEventSink(sink) + wrapper.onListen(p0, pigeonSink!!) + } + + override fun onCancel(p0: Any?) { + pigeonSink = null + wrapper.onCancel(p0) + } +} + +interface MessagesPigeonEventChannelWrapper { + open fun onListen(p0: Any?, sink: PigeonEventSink) {} + + open fun onCancel(p0: Any?) {} +} + +class PigeonEventSink(private val sink: EventChannel.EventSink) { + fun success(value: T) { + sink.success(value) + } + + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink.error(errorCode, errorMessage, errorDetails) + } + + fun endOfStream() { + sink.endOfStream() + } +} + +abstract class VideoEventsStreamHandler : MessagesPigeonEventChannelWrapper { + companion object { + fun register( + messenger: BinaryMessenger, + streamHandler: VideoEventsStreamHandler, + instanceName: String = "" + ) { + var channelName: String = + "dev.flutter.pigeon.video_player_android.VideoEventChannel.videoEvents" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" + } + val internalStreamHandler = MessagesPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, MessagesPigeonMethodCodec) + .setStreamHandler(internalStreamHandler) + } + } + // Implement methods from MessagesPigeonEventChannelWrapper + override fun onListen(p0: Any?, sink: PigeonEventSink) {} + + override fun onCancel(p0: Any?) {} +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java new file mode 100644 index 00000000..529e8d5a --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java @@ -0,0 +1,115 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link ExoPlayerEventListener}. + * + *

This test suite narrowly verifies that the events emitted by the underlying {@link + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected). + */ +@RunWith(RobolectricTestRunner.class) +public final class ExoPlayerEventListenerTest { + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockCallbacks; + private TestExoPlayerEventListener eventListener; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + /** + * A test subclass of {@link ExoPlayerEventListener} that exposes the abstract class for testing. + */ + private static final class TestExoPlayerEventListener extends ExoPlayerEventListener { + private boolean calledSendInitialized = false; + + public TestExoPlayerEventListener(ExoPlayer exoPlayer, VideoPlayerCallbacks callbacks) { + super(exoPlayer, callbacks); + } + + @Override + protected void sendInitialized() { + calledSendInitialized = true; + } + + boolean calledSendInitialized() { + return calledSendInitialized; + } + } + + @Before + public void setUp() { + eventListener = new TestExoPlayerEventListener(mockExoPlayer, mockCallbacks); + } + + @Test + public void onPlaybackStateChangedBufferingSendsBuffering() { + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING); + + verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.BUFFERING); + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedEndedSendsEnded() { + eventListener.onPlaybackStateChanged(Player.STATE_ENDED); + + verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.ENDED); + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedIdleSendsIdle() { + eventListener.onPlaybackStateChanged(Player.STATE_IDLE); + + verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.IDLE); + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedReadySendsInitializedAndReady() { + eventListener.onPlaybackStateChanged(Player.STATE_READY); + + verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.READY); + verifyNoMoreInteractions(mockCallbacks); + assertTrue(eventListener.calledSendInitialized()); + } + + @Test + public void onErrorBehindLiveWindowSeekToDefaultAndPrepare() { + eventListener.onPlayerError( + new PlaybackException("SORT_OF_OK", null, PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW)); + + verify(mockExoPlayer).seekToDefaultPosition(); + verify(mockExoPlayer).prepare(); + verifyNoInteractions(mockCallbacks); + } + + @Test + public void onIsPlayingChangedToggled() { + eventListener.onIsPlayingChanged(true); + verify(mockCallbacks).onIsPlayingStateUpdate(true); + + eventListener.onIsPlayingChanged(false); + verify(mockCallbacks).onIsPlayingStateUpdate(false); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java new file mode 100644 index 00000000..5b421e3a --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java @@ -0,0 +1,37 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.FakeMediaSourceFactory; + +/** A fake implementation of the {@link VideoAsset} class. */ +final class FakeVideoAsset extends VideoAsset { + @NonNull private final MediaSource.Factory mediaSourceFactory; + + FakeVideoAsset(String assetUrl) { + this(assetUrl, new FakeMediaSourceFactory()); + } + + FakeVideoAsset(String assetUrl, @NonNull MediaSource.Factory mediaSourceFactory) { + super(assetUrl); + this.mediaSourceFactory = mediaSourceFactory; + } + + @NonNull + @Override + public MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); + } + + @NonNull + @Override + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) { + return mediaSourceFactory; + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewFactoryTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewFactoryTest.java new file mode 100644 index 00000000..23a3d03e --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewFactoryTest.java @@ -0,0 +1,45 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.*; + +import android.content.Context; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.test.core.app.ApplicationProvider; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugins.videoplayer.platformview.PlatformVideoView; +import io.flutter.plugins.videoplayer.platformview.PlatformVideoViewFactory; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class PlatformVideoViewFactoryTest { + @Test + public void createsPlatformVideoViewBasedOnSuppliedArguments() { + final PlatformVideoViewFactory.VideoPlayerProvider videoPlayerProvider = + mock(PlatformVideoViewFactory.VideoPlayerProvider.class); + final VideoPlayer videoPlayer = mock(VideoPlayer.class); + final Context context = ApplicationProvider.getApplicationContext(); + final ExoPlayer exoPlayer = new ExoPlayer.Builder(context).build(); + final long playerId = 1L; + + when(videoPlayerProvider.getVideoPlayer(playerId)).thenReturn(videoPlayer); + when(videoPlayer.getExoPlayer()).thenReturn(exoPlayer); + + final PlatformVideoViewFactory factory = new PlatformVideoViewFactory(videoPlayerProvider); + final PlatformVideoViewCreationParams args = new PlatformVideoViewCreationParams(playerId); + + final PlatformView view = factory.create(context, 0, args); + + assertTrue(view instanceof PlatformVideoView); + verify(videoPlayerProvider).getVideoPlayer(playerId); + verify(videoPlayer).getExoPlayer(); + + exoPlayer.release(); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java new file mode 100644 index 00000000..3beab425 --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java @@ -0,0 +1,39 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.view.SurfaceView; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.test.core.app.ApplicationProvider; +import io.flutter.plugins.videoplayer.platformview.PlatformVideoView; +import java.lang.reflect.Field; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit tests for {@link PlatformVideoViewTest}. */ +@RunWith(RobolectricTestRunner.class) +public class PlatformVideoViewTest { + @Test + public void createsSurfaceViewAndSetsItForExoPlayer() throws Exception { + final Context context = ApplicationProvider.getApplicationContext(); + final ExoPlayer exoPlayer = spy(new ExoPlayer.Builder(context).build()); + + final PlatformVideoView view = new PlatformVideoView(context, exoPlayer); + + final Field field = PlatformVideoView.class.getDeclaredField("surfaceView"); + field.setAccessible(true); + final SurfaceView surfaceView = (SurfaceView) field.get(view); + + assertNotNull(surfaceView); + verify(exoPlayer).setVideoSurfaceView(surfaceView); + + exoPlayer.release(); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformViewExoPlayerEventListenerTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformViewExoPlayerEventListenerTest.java new file mode 100644 index 00000000..516b857b --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformViewExoPlayerEventListenerTest.java @@ -0,0 +1,80 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.media3.common.Format; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.platformview.PlatformViewExoPlayerEventListener; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link PlatformViewExoPlayerEventListener}. + * + *

This test suite narrowly verifies that the events emitted by the underlying {@link + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected). + */ +@RunWith(RobolectricTestRunner.class) +public final class PlatformViewExoPlayerEventListenerTest { + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockCallbacks; + private ExoPlayerEventListener eventListener; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks); + } + + @Test + public void onPlaybackStateChangedReadySendInitialized() { + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks); + + Format format = new Format.Builder().setWidth(800).setHeight(400).build(); + when(mockExoPlayer.getVideoFormat()).thenReturn(format); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 0); + } + + @Test + public void onPlaybackStateChangedReadyInPortraitMode90DegreesSwapsWidthAndHeight() { + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks); + + Format format = + new Format.Builder().setWidth(800).setHeight(400).setRotationDegrees(90).build(); + when(mockExoPlayer.getVideoFormat()).thenReturn(format); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(400, 800, 10L, 0); + } + + @Test + public void onPlaybackStateChangedReadyInPortraitMode270DegreesSwapsWidthAndHeight() { + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks); + + Format format = + new Format.Builder().setWidth(800).setHeight(400).setRotationDegrees(270).build(); + when(mockExoPlayer.getVideoFormat()).thenReturn(format); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(400, 800, 10L, 0); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureExoPlayerEventListenerTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureExoPlayerEventListenerTest.java new file mode 100644 index 00000000..e71cf619 --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureExoPlayerEventListenerTest.java @@ -0,0 +1,127 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.media3.common.Format; +import androidx.media3.common.Player; +import androidx.media3.common.VideoSize; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.texture.TextureExoPlayerEventListener; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link TextureExoPlayerEventListener}. + * + *

This test suite narrowly verifies that the events emitted by the underlying {@link + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected). + */ +@RunWith(RobolectricTestRunner.class) +public class TextureExoPlayerEventListenerTest { + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockCallbacks; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Test + public void + onPlaybackStateChangedReadySendInitialized_whenSurfaceProducerHandlesCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true); + VideoSize size = new VideoSize(800, 400, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 0); + } + + @Test + public void + onPlaybackStateChangedReadySendInitializedWithRotationCorrectionAndWidthAndHeightSwap_whenSurfaceProducerDoesNotHandleCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false); + VideoSize size = new VideoSize(800, 400, 0); + int rotationCorrection = 90; + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build(); + + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, rotationCorrection); + } + + @Test + public void + onPlaybackStateChangedReadyInPortraitMode90DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerHandlesCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true); + VideoSize size = new VideoSize(800, 400, 0); + + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 0); + } + + @Test + public void + onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight_whenSurfaceProducerDoesNotHandleCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false); + VideoSize size = new VideoSize(800, 400, 0); + int rotationCorrection = 90; + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build(); + + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 90); + } + + @Test + public void + onPlaybackStateChangedReadyInPortraitMode270DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerHandlesCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true); + VideoSize size = new VideoSize(800, 400, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 0); + } + + @Test + public void + onPlaybackStateChangedReadyInPortraitMode270DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerDoesNotHandleCropAndRotation() { + TextureExoPlayerEventListener eventListener = + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false); + VideoSize size = new VideoSize(800, 400, 0); + int rotationCorrection = 270; + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build(); + + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 270); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureVideoPlayerTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureVideoPlayerTest.java new file mode 100644 index 00000000..6631d35a --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureVideoPlayerTest.java @@ -0,0 +1,203 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import android.view.Surface; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.VideoSize; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.plugins.videoplayer.texture.TextureVideoPlayer; +import io.flutter.view.TextureRegistry; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link TextureVideoPlayer}. + * + *

This test suite narrowly verifies that {@link TextureVideoPlayer} interfaces with the + * {@link ExoPlayer} interface exactly as it did when the test suite was created. That is, + * if the behavior changes, this test will need to change. However, this suite should catch bugs + * related to "this is a safe refactor with no behavior changes". + */ +@RunWith(RobolectricTestRunner.class) +public final class TextureVideoPlayerTest { + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; + private FakeVideoAsset fakeVideoAsset; + + @Mock private VideoPlayerCallbacks mockEvents; + @Mock private TextureRegistry.SurfaceProducer mockProducer; + @Mock private ExoPlayer mockExoPlayer; + @Captor private ArgumentCaptor attributesCaptor; + @Captor private ArgumentCaptor callbackCaptor; + @Captor private ArgumentCaptor listenerCaptor; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); + when(mockProducer.getSurface()).thenReturn(mock(Surface.class)); + when(mockProducer.handlesCropAndRotation()).thenReturn(true); + } + + private VideoPlayer createVideoPlayer() { + return createVideoPlayer(new VideoPlayerOptions()); + } + + private TextureVideoPlayer createVideoPlayer(VideoPlayerOptions options) { + return new TextureVideoPlayer( + mockEvents, mockProducer, fakeVideoAsset.getMediaItem(), options, () -> mockExoPlayer); + } + + @Test + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); + verify(mockExoPlayer).prepare(); + verify(mockProducer).getSurface(); + verify(mockExoPlayer).setVideoSurface(any()); + + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); + assertEquals(C.AUDIO_CONTENT_TYPE_MOVIE, attributesCaptor.getValue().contentType); + + videoPlayer.dispose(); + } + + @Test + public void onSurfaceProducerCleanupAndAvailableRemovesAndRestoresSurface() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockProducer).setCallback(callbackCaptor.capture()); + verify(mockExoPlayer).setVideoSurface(notNull()); + verify(mockExoPlayer, never()).release(); + + when(mockExoPlayer.getCurrentPosition()).thenReturn(10L); + when(mockExoPlayer.getRepeatMode()).thenReturn(Player.REPEAT_MODE_ALL); + when(mockExoPlayer.getVolume()).thenReturn(0.5f); + when(mockExoPlayer.getPlaybackParameters()).thenReturn(new PlaybackParameters(2.5f)); + + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue(); + producerLifecycle.onSurfaceCleanup(); + + verify(mockExoPlayer).setVideoSurface(null); + + reset(mockExoPlayer); + producerLifecycle.onSurfaceAvailable(); + + verify(mockExoPlayer).setVideoSurface(notNull()); + + videoPlayer.dispose(); + } + + @Test + public void onSurfaceProducerCleanupDoesNotStopOrPauseVideo() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockProducer).setCallback(callbackCaptor.capture()); + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue(); + producerLifecycle.onSurfaceCleanup(); + + verify(mockExoPlayer, never()).stop(); + verify(mockExoPlayer, never()).pause(); + verify(mockExoPlayer, never()).setPlayWhenReady(anyBoolean()); + + videoPlayer.dispose(); + } + + @Test + public void onInitializedCalledWhenVideoPlayerInitiallyCreated() { + VideoPlayer videoPlayer = createVideoPlayer(); + + // Pretend we have a video, and capture the registered event listener. + when(mockExoPlayer.getVideoSize()).thenReturn(new VideoSize(300, 200)); + verify(mockExoPlayer).addListener(listenerCaptor.capture()); + Player.Listener listener = listenerCaptor.getValue(); + + // Trigger an event that would trigger onInitialized. + listener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockEvents).onInitialized(anyInt(), anyInt(), anyLong(), anyInt()); + + videoPlayer.dispose(); + } + + @Test + public void onSurfaceAvailableDoesNotSendInitializeEventAgain() { + // The VideoPlayer contract assumes that the event "initialized" is sent exactly once + // (duplicate events cause an error to be thrown at the shared Dart layer). This test verifies + // that the onInitialized event is sent exactly once per player. + // + // Regression test for https://github.com/flutter/flutter/issues/154602. + VideoPlayer videoPlayer = createVideoPlayer(); + when(mockExoPlayer.getVideoSize()).thenReturn(new VideoSize(300, 200)); + + // Capture the lifecycle events so we can simulate onSurfaceAvailable/Cleanup. + verify(mockProducer).setCallback(callbackCaptor.capture()); + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue(); + + verify(mockExoPlayer).addListener(listenerCaptor.capture()); + Player.Listener listener = listenerCaptor.getValue(); + + // Trigger ready state, which should trigger initialization. + listener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockEvents).onInitialized(anyInt(), anyInt(), anyLong(), anyInt()); + + // Trigger cleanup/available. + producerLifecycle.onSurfaceCleanup(); + producerLifecycle.onSurfaceAvailable(); + + // It should still only have been called the one time. + verify(mockEvents).onInitialized(anyInt(), anyInt(), anyLong(), anyInt()); + + videoPlayer.dispose(); + } + + @Test + public void onSurfaceAvailableWithoutCleanupDoesNotRecreate() { + // Initially create the video player, which creates the initial surface. + VideoPlayer videoPlayer = createVideoPlayer(); + verify(mockProducer).getSurface(); + verify(mockProducer).handlesCropAndRotation(); + + // Capture the lifecycle events so we can simulate onSurfaceAvailable/Cleanup. + verify(mockProducer).setCallback(callbackCaptor.capture()); + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue(); + + // Calling onSurfaceAvailable does not do anything, since the surface was never cleaned up. + producerLifecycle.onSurfaceAvailable(); + verifyNoMoreInteractions(mockProducer); + + videoPlayer.dispose(); + } + + @Test + public void disposeReleasesExoPlayerBeforeTexture() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.dispose(); + + // Regression test for https://github.com/flutter/flutter/issues/156158. + // The player must be destroyed before the surface it is writing to. + InOrder inOrder = inOrder(mockExoPlayer, mockProducer); + inOrder.verify(mockExoPlayer).release(); + inOrder.verify(mockProducer).release(); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java new file mode 100644 index 00000000..5544cc40 --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java @@ -0,0 +1,140 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import androidx.media3.common.MediaItem; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.test.core.app.ApplicationProvider; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link VideoAsset}. + * + *

This test suite narrowly verifies that the {@link VideoAsset} factory methods, {@link + * VideoAsset#fromRemoteUrl(String, VideoAsset.StreamingFormat, Map)} and {@link + * VideoAsset#fromAssetUrl(String)} follow the contract they have documented. + * + *

In other tests of the player, a fake asset is likely to be used. + */ +@RunWith(RobolectricTestRunner.class) +public final class VideoAssetTest { + @Test + public void localVideoRequiresAssetUrl() { + assertThrows( + IllegalArgumentException.class, + () -> VideoAsset.fromAssetUrl("https://not.local/video.mp4")); + } + + @Test + public void localVideoCreatesMediaItem() { + VideoAsset asset = VideoAsset.fromAssetUrl("asset:///asset-key"); + MediaItem mediaItem = asset.getMediaItem(); + + assert mediaItem.localConfiguration != null; + assertEquals(mediaItem.localConfiguration.uri, Uri.parse("asset:///asset-key")); + } + + private static DefaultHttpDataSource.Factory mockHttpFactory() { + DefaultHttpDataSource.Factory httpFactory = mock(DefaultHttpDataSource.Factory.class); + when(httpFactory.setUserAgent(any())).thenReturn(httpFactory); + when(httpFactory.setAllowCrossProtocolRedirects(anyBoolean())).thenReturn(httpFactory); + when(httpFactory.setDefaultRequestProperties(anyMap())).thenReturn(httpFactory); + return httpFactory; + } + + @Test + public void remoteVideoSetsUserAgentAndCrossProtocolRedirects() { + final String userAgent = "A User Agent"; + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", + VideoAsset.StreamingFormat.UNKNOWN, + new HashMap<>(), + userAgent); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + + // Cast to HttpVideoAsset to call a testing-only method to intercept calls. + ((HttpVideoAsset) asset) + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent(userAgent); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory, never()).setDefaultRequestProperties(anyMap()); + } + + // This tests that without using the overrides we get a working, non-mocked object. + // + // I guess you could also start a local HTTP server, and try fetching with it, YMMV. + @Test + public void remoteVideoGetMediaSourceFactoryInProductionReturnsRealMediaSource() { + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", + VideoAsset.StreamingFormat.UNKNOWN, + new HashMap<>(), + null); + + MediaSource source = + asset + .getMediaSourceFactory(ApplicationProvider.getApplicationContext()) + .createMediaSource(asset.getMediaItem()); + assertEquals( + Uri.parse("https://flutter.dev/video.mp4"), + Objects.requireNonNull(source.getMediaItem().localConfiguration).uri); + } + + @Test + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() { + Map headers = new HashMap<>(); + headers.put("X-Cache-Forever", "true"); + + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers, null); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + + // Cast to HttpVideoAsset to call a testing-only method to intercept calls. + ((HttpVideoAsset) asset) + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory).setDefaultRequestProperties(headers); + } + + @Test + public void rtspVideoRequiresRtspUrl() { + assertThrows( + IllegalArgumentException.class, () -> VideoAsset.fromRtspUrl("https://not.rtsp/video.mp4")); + } + + @Test + public void rtspVideoCreatesMediaItem() { + VideoAsset asset = VideoAsset.fromRtspUrl("rtsp://test:pass@flutter.dev/stream"); + MediaItem mediaItem = asset.getMediaItem(); + + assert mediaItem.localConfiguration != null; + assertEquals( + mediaItem.localConfiguration.uri, Uri.parse("rtsp://test:pass@flutter.dev/stream")); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java new file mode 100644 index 00000000..acb3bfd2 --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java @@ -0,0 +1,91 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests {@link VideoPlayerEventCallbacks}. + * + *

This test suite narrowly verifies that calling the provided event callbacks, such as + * {@link VideoPlayerEventCallbacks#onPlaybackStateChanged(PlatformPlaybackState)}, produces the + * expected data as an encoded {@link Map}. + * + *

In other words, this tests that "the Java-side of the event channel works as expected". + */ +@RunWith(RobolectricTestRunner.class) +public final class VideoPlayerEventCallbacksTest { + private VideoPlayerEventCallbacks eventCallbacks; + + @Mock private QueuingEventSink mockEventSink; + + @Captor private ArgumentCaptor eventCaptor; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + eventCallbacks = VideoPlayerEventCallbacks.withSink(mockEventSink); + } + + @Test + public void onInitializedSendsExpectedArguments() { + final int width = 800; + final int height = 600; + final long duration = 10L; + final int rotation = 180; + eventCallbacks.onInitialized(width, height, duration, rotation); + + verify(mockEventSink).success(eventCaptor.capture()); + + PlatformVideoEvent actual = eventCaptor.getValue(); + InitializationEvent expected = new InitializationEvent(duration, width, height, rotation); + assertEquals(expected, actual); + } + + @Test + public void onPlaybackStateChanged() { + PlatformPlaybackState state = PlatformPlaybackState.READY; + eventCallbacks.onPlaybackStateChanged(state); + + verify(mockEventSink).success(eventCaptor.capture()); + + PlatformVideoEvent actual = eventCaptor.getValue(); + PlaybackStateChangeEvent expected = new PlaybackStateChangeEvent(state); + assertEquals(expected, actual); + } + + @Test + public void onError() { + eventCallbacks.onError("code", "message", "details"); + + verify(mockEventSink).error(eq("code"), eq("message"), eq("details")); + } + + @Test + public void onIsPlayingStateUpdate() { + eventCallbacks.onIsPlayingStateUpdate(true); + + verify(mockEventSink).success(eventCaptor.capture()); + + PlatformVideoEvent actual = eventCaptor.getValue(); + IsPlayingStateEvent expected = new IsPlayingStateEvent(true); + assertEquals(expected, actual); + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerPluginTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerPluginTest.java new file mode 100644 index 00000000..6093dc86 --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerPluginTest.java @@ -0,0 +1,114 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.util.LongSparseArray; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.platform.PlatformViewRegistry; +import io.flutter.plugins.videoplayer.platformview.PlatformVideoViewFactory; +import io.flutter.plugins.videoplayer.platformview.PlatformViewVideoPlayer; +import io.flutter.plugins.videoplayer.texture.TextureVideoPlayer; +import io.flutter.view.TextureRegistry; +import java.lang.reflect.Field; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class VideoPlayerPluginTest { + @Mock private TextureRegistry mockTextureRegistry; + @Mock private TextureRegistry.SurfaceProducer mockSurfaceProducer; + @Mock private PlatformViewRegistry mockPlatformViewRegistry; + private VideoPlayerPlugin plugin; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(mockTextureRegistry.createSurfaceProducer()).thenReturn(mockSurfaceProducer); + + FlutterPlugin.FlutterPluginBinding binding = mock(FlutterPlugin.FlutterPluginBinding.class); + when(binding.getApplicationContext()).thenReturn(mock(Context.class)); + when(binding.getTextureRegistry()).thenReturn(mockTextureRegistry); + when(binding.getBinaryMessenger()) + .thenReturn(mock(io.flutter.plugin.common.BinaryMessenger.class)); + when(binding.getPlatformViewRegistry()).thenReturn(mockPlatformViewRegistry); + + plugin = new VideoPlayerPlugin(); + plugin.onAttachedToEngine(binding); + } + + @SuppressWarnings("unchecked") + private LongSparseArray getVideoPlayers() throws Exception { + final Field field = VideoPlayerPlugin.class.getDeclaredField("videoPlayers"); + field.setAccessible(true); + return (LongSparseArray) field.get(plugin); + } + + // This is only a placeholder test and doesn't actually initialize the plugin. + @Test + public void initPluginDoesNotThrow() { + final VideoPlayerPlugin plugin = new VideoPlayerPlugin(); + } + + @Test + public void registersPlatformVideoViewFactory() { + verify(mockPlatformViewRegistry) + .registerViewFactory( + eq("plugins.flutter.dev/video_player_android"), any(PlatformVideoViewFactory.class)); + } + + @Test + public void createsPlatformViewVideoPlayer() throws Exception { + try (MockedStatic mockedPlatformViewVideoPlayerStatic = + mockStatic(PlatformViewVideoPlayer.class)) { + mockedPlatformViewVideoPlayerStatic + .when(() -> PlatformViewVideoPlayer.create(any(), any(), any(), any())) + .thenReturn(mock(PlatformViewVideoPlayer.class)); + + final CreationOptions options = + new CreationOptions( + "https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4", + null, + new HashMap<>(), + null); + + final long playerId = plugin.createForPlatformView(options); + + final LongSparseArray videoPlayers = getVideoPlayers(); + assertTrue(videoPlayers.get(playerId) instanceof PlatformViewVideoPlayer); + } + } + + @Test + public void createsTextureVideoPlayer() throws Exception { + try (MockedStatic mockedTextureVideoPlayerStatic = + mockStatic(TextureVideoPlayer.class)) { + mockedTextureVideoPlayerStatic + .when(() -> TextureVideoPlayer.create(any(), any(), any(), any(), any())) + .thenReturn(mock(TextureVideoPlayer.class)); + + final CreationOptions options = + new CreationOptions( + "https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4", + null, + new HashMap<>(), + null); + + final TexturePlayerIds ids = plugin.createForTextureView(options); + + final LongSparseArray videoPlayers = getVideoPlayers(); + assertTrue(videoPlayers.get(ids.getPlayerId()) instanceof TextureVideoPlayer); + } + } +} diff --git a/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java new file mode 100644 index 00000000..92c2ff5f --- /dev/null +++ b/packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java @@ -0,0 +1,623 @@ +// 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. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.*; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; +import androidx.media3.common.Tracks; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import com.google.common.collect.ImmutableList; +import io.flutter.plugins.videoplayer.platformview.PlatformViewExoPlayerEventListener; +import io.flutter.view.TextureRegistry.SurfaceProducer; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link VideoPlayer}. + * + *

This test suite narrowly verifies that {@link VideoPlayer} interfaces with the {@link + * ExoPlayer} interface exactly as it did when the test suite was created. That is, if the + * behavior changes, this test will need to change. However, this suite should catch bugs related to + * "this is a safe refactor with no behavior changes". + * + *

It's hypothetically possible to write better tests using {@link + * androidx.media3.test.utils.FakeMediaSource}, but you really need a PhD in the Android media APIs + * in order to figure out how to set everything up so the player "works". + */ +@RunWith(RobolectricTestRunner.class) +public final class VideoPlayerTest { + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; + private FakeVideoAsset fakeVideoAsset; + + @Mock private VideoPlayerCallbacks mockEvents; + @Mock private ExoPlayer mockExoPlayer; + @Captor private ArgumentCaptor attributesCaptor; + @Captor private ArgumentCaptor listenerCaptor; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + /** A test subclass of {@link VideoPlayer} that exposes the abstract class for testing. */ + private final class TestVideoPlayer extends VideoPlayer { + private TestVideoPlayer( + @NonNull VideoPlayerCallbacks events, + @NonNull MediaItem mediaItem, + @NonNull VideoPlayerOptions options, + @Nullable SurfaceProducer surfaceProducer, + @NonNull ExoPlayerProvider exoPlayerProvider) { + super(events, mediaItem, options, surfaceProducer, exoPlayerProvider); + } + + @NonNull + @Override + protected ExoPlayerEventListener createExoPlayerEventListener( + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer) { + // Use platform view implementation for testing. + return new PlatformViewExoPlayerEventListener(exoPlayer, mockEvents); + } + } + + @Before + public void setUp() { + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); + } + + private VideoPlayer createVideoPlayer() { + return createVideoPlayer(new VideoPlayerOptions()); + } + + private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { + return new TestVideoPlayer( + mockEvents, fakeVideoAsset.getMediaItem(), options, null, () -> mockExoPlayer); + } + + @Test + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); + verify(mockExoPlayer).prepare(); + + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); + assertEquals(C.AUDIO_CONTENT_TYPE_MOVIE, attributesCaptor.getValue().contentType); + + videoPlayer.dispose(); + } + + @Test + public void loadsAndPreparesProvidedMediaDisablesAudioFocusWhenMixModeSet() { + VideoPlayerOptions options = new VideoPlayerOptions(); + options.mixWithOthers = true; + + VideoPlayer videoPlayer = createVideoPlayer(options); + + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(false)); + assertEquals(C.AUDIO_CONTENT_TYPE_MOVIE, attributesCaptor.getValue().contentType); + + videoPlayer.dispose(); + } + + @Test + public void playsAndPausesProvidedMedia() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.play(); + verify(mockExoPlayer).play(); + + videoPlayer.pause(); + verify(mockExoPlayer).pause(); + + videoPlayer.dispose(); + } + + @Test + public void togglesLoopingEnablesAndDisablesRepeatMode() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.setLooping(true); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); + + videoPlayer.setLooping(false); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_OFF); + + videoPlayer.dispose(); + } + + @Test + public void setVolumeIsClampedBetween0and1() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.setVolume(-1.0); + verify(mockExoPlayer).setVolume(0f); + + videoPlayer.setVolume(2.0); + verify(mockExoPlayer).setVolume(1f); + + videoPlayer.setVolume(0.5); + verify(mockExoPlayer).setVolume(0.5f); + + videoPlayer.dispose(); + } + + @Test + public void setPlaybackSpeedSetsPlaybackParametersWithValue() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.setPlaybackSpeed(2.5); + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); + + videoPlayer.dispose(); + } + + @Test + public void seekTo() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.seekTo(10L); + verify(mockExoPlayer).seekTo(10); + + videoPlayer.dispose(); + } + + @Test + public void getCurrentPosition() { + VideoPlayer videoPlayer = createVideoPlayer(); + + final long playbackPosition = 20L; + when(mockExoPlayer.getCurrentPosition()).thenReturn(playbackPosition); + + final Long position = videoPlayer.getCurrentPosition(); + assertEquals(playbackPosition, position.longValue()); + + videoPlayer.dispose(); + } + + @Test + public void getBufferedPosition() { + VideoPlayer videoPlayer = createVideoPlayer(); + + final long bufferedPosition = 10L; + when(mockExoPlayer.getBufferedPosition()).thenReturn(bufferedPosition); + + final Long position = videoPlayer.getBufferedPosition(); + assertEquals(bufferedPosition, position.longValue()); + + videoPlayer.dispose(); + } + + @Test + public void onInitializedCalledWhenVideoPlayerInitiallyAvailable() { + VideoPlayer videoPlayer = createVideoPlayer(); + + // Pretend we have a video, and capture the registered event listener. + when(mockExoPlayer.getVideoFormat()) + .thenReturn( + new Format.Builder().setWidth(300).setHeight(200).setRotationDegrees(0).build()); + verify(mockExoPlayer).addListener(listenerCaptor.capture()); + Player.Listener listener = listenerCaptor.getValue(); + + // Trigger an event that would trigger onInitialized. + listener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockEvents).onInitialized(anyInt(), anyInt(), anyLong(), anyInt()); + + videoPlayer.dispose(); + } + + @Test + public void disposeReleasesExoPlayer() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.dispose(); + + verify(mockExoPlayer).release(); + } + + // Helper method to set the length field on a mocked Tracks.Group + private void setGroupLength(Tracks.Group group, int length) { + try { + Field lengthField = group.getClass().getDeclaredField("length"); + lengthField.setAccessible(true); + lengthField.setInt(group, length); + } catch (Exception e) { + throw new RuntimeException("Failed to set length field", e); + } + } + + @Test + public void testGetAudioTracks_withMultipleAudioTracks() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class); + Tracks.Group mockAudioGroup2 = mock(Tracks.Group.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Create mock formats for audio tracks + Format audioFormat1 = + new Format.Builder() + .setId("audio_track_1") + .setLabel("English") + .setLanguage("en") + .setAverageBitrate(128000) + .setSampleRate(48000) + .setChannelCount(2) + .setCodecs("mp4a.40.2") + .build(); + + Format audioFormat2 = + new Format.Builder() + .setId("audio_track_2") + .setLabel("Español") + .setLanguage("es") + .setAverageBitrate(96000) + .setSampleRate(44100) + .setChannelCount(2) + .setCodecs("mp4a.40.2") + .build(); + + // Mock audio groups and set length field + setGroupLength(mockAudioGroup1, 1); + setGroupLength(mockAudioGroup2, 1); + + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + + when(mockAudioGroup2.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup2.getTrackFormat(0)).thenReturn(audioFormat2); + when(mockAudioGroup2.isTrackSelected(0)).thenReturn(false); + + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + + // Mock tracks + ImmutableList groups = + ImmutableList.of(mockAudioGroup1, mockAudioGroup2, mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify first track + ExoPlayerAudioTrackData track1 = result.get(0); + assertEquals(0L, track1.getGroupIndex()); + assertEquals(0L, track1.getTrackIndex()); + assertEquals("English", track1.getLabel()); + assertEquals("en", track1.getLanguage()); + assertTrue(track1.isSelected()); + assertEquals(Long.valueOf(128000), track1.getBitrate()); + assertEquals(Long.valueOf(48000), track1.getSampleRate()); + assertEquals(Long.valueOf(2), track1.getChannelCount()); + assertEquals("mp4a.40.2", track1.getCodec()); + + // Verify second track + ExoPlayerAudioTrackData track2 = result.get(1); + assertEquals(1L, track2.getGroupIndex()); + assertEquals(0L, track2.getTrackIndex()); + assertEquals("Español", track2.getLabel()); + assertEquals("es", track2.getLanguage()); + assertFalse(track2.isSelected()); + assertEquals(Long.valueOf(96000), track2.getBitrate()); + assertEquals(Long.valueOf(44100), track2.getSampleRate()); + assertEquals(Long.valueOf(2), track2.getChannelCount()); + assertEquals("mp4a.40.2", track2.getCodec()); + + videoPlayer.dispose(); + } + + @Test + public void testGetAudioTracks_withNoAudioTracks() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Mock video group only (no audio tracks) + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(0, result.size()); + + videoPlayer.dispose(); + } + + @Test + public void testGetAudioTracks_withNullValues() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class); + + // Create format with null/missing values + Format audioFormat = + new Format.Builder() + .setId("audio_track_null") + .setLabel(null) // Null label + .setLanguage(null) // Null language + .setAverageBitrate(Format.NO_VALUE) // No bitrate + .setSampleRate(Format.NO_VALUE) // No sample rate + .setChannelCount(Format.NO_VALUE) // No channel count + .setCodecs(null) // Null codec + .build(); + + // Mock audio group and set length field + setGroupLength(mockAudioGroup1, 1); + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(false); + + ImmutableList groups = ImmutableList.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(1, result.size()); + + ExoPlayerAudioTrackData track = result.get(0); + assertEquals(0L, track.getGroupIndex()); + assertEquals(0L, track.getTrackIndex()); + assertNull(track.getLabel()); // Null values should be preserved + assertNull(track.getLanguage()); // Null values should be preserved + assertFalse(track.isSelected()); + assertNull(track.getBitrate()); + assertNull(track.getSampleRate()); + assertNull(track.getChannelCount()); + assertNull(track.getCodec()); + + videoPlayer.dispose(); + } + + @Test + public void testGetAudioTracks_withMultipleTracksInSameGroup() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class); + + // Create format for group with multiple tracks + Format audioFormat1 = + new Format.Builder() + .setId("audio_track_1") + .setLabel("Track 1") + .setLanguage("en") + .setAverageBitrate(128000) + .build(); + + Format audioFormat2 = + new Format.Builder() + .setId("audio_track_2") + .setLabel("Track 2") + .setLanguage("en") + .setAverageBitrate(192000) + .build(); + + // Mock audio group with multiple tracks + setGroupLength(mockAudioGroup1, 2); + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1); + when(mockAudioGroup1.getTrackFormat(1)).thenReturn(audioFormat2); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + when(mockAudioGroup1.isTrackSelected(1)).thenReturn(false); + + ImmutableList groups = ImmutableList.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify track indices are correct + ExoPlayerAudioTrackData track1 = result.get(0); + ExoPlayerAudioTrackData track2 = result.get(1); + assertEquals(0L, track1.getGroupIndex()); + assertEquals(0L, track1.getTrackIndex()); + assertEquals(0L, track2.getGroupIndex()); + assertEquals(1L, track2.getTrackIndex()); + // Tracks have same group but different track indices + assertEquals(track1.getGroupIndex(), track2.getGroupIndex()); + assertNotEquals(track1.getTrackIndex(), track2.getTrackIndex()); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_validIndices() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + DefaultTrackSelector.Parameters mockParameters = mock(DefaultTrackSelector.Parameters.class); + DefaultTrackSelector.Parameters.Builder mockBuilder = + mock(DefaultTrackSelector.Parameters.Builder.class); + + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + Format audioFormat = + new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build(); + + // Create a real TrackGroup with the format + TrackGroup trackGroup = new TrackGroup(audioFormat); + + // Mock audio group with 2 tracks + setGroupLength(mockAudioGroup, 2); + when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup.getMediaTrackGroup()).thenReturn(trackGroup); + + ImmutableList groups = ImmutableList.of(mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + + // Set up track selector BEFORE creating VideoPlayer + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder); + when(mockBuilder.setOverrideForType(any(TrackSelectionOverride.class))).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockParameters); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test selecting a valid audio track + videoPlayer.selectAudioTrack(0, 0); + + // Verify track selector was called + verify(mockTrackSelector).buildUponParameters(); + verify(mockBuilder).setOverrideForType(any(TrackSelectionOverride.class)); + verify(mockBuilder).build(); + verify(mockTrackSelector).setParameters(mockParameters); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_nullTrackSelector() { + // Track selector is null by default in mock + VideoPlayer videoPlayer = createVideoPlayer(); + + assertThrows(IllegalStateException.class, () -> videoPlayer.selectAudioTrack(0, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_invalidGroupIndex() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + Format audioFormat = + new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build(); + + ImmutableList groups = ImmutableList.of(mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test with invalid group index (only 1 group exists at index 0) + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(5, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_invalidTrackIndex() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + Format audioFormat = + new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build(); + + // Mock audio group with only 1 track + setGroupLength(mockAudioGroup, 1); + when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + + ImmutableList groups = ImmutableList.of(mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test with invalid track index (only 1 track exists at index 0) + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(0, 5)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_nonAudioGroup() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Mock video group (not audio) + setGroupLength(mockVideoGroup, 1); + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test selecting from a non-audio group + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(0, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectAudioTrack_negativeIndices() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + Format audioFormat = + new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build(); + + ImmutableList groups = ImmutableList.of(mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test with negative indices - should be caught by bounds checking + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(-1, 0)); + + videoPlayer.dispose(); + } +} diff --git a/packages/video_player_android/example/android/app/build.gradle b/packages/video_player_android/example/android/app/build.gradle new file mode 100644 index 00000000..ad246410 --- /dev/null +++ b/packages/video_player_android/example/android/app/build.gradle @@ -0,0 +1,69 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace = "io.flutter.plugins.videoplayerexample" + compileSdk = flutter.compileSdkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId "io.flutter.plugins.videoplayerexample" + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } + lint { + disable 'InvalidPackage' + } +} + +flutter { + source = '../..' +} + +dependencies { + testImplementation("androidx.test.ext:junit:1.2.1") + testImplementation("com.google.truth:truth:1.1.3") + testImplementation("junit:junit:4.13") + testImplementation("org.robolectric:robolectric:4.16") + testImplementation("org.mockito:mockito-core:5.17.0") + androidTestImplementation("androidx.test:runner:1.1.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.1.1") + implementation(project(':espresso')) + api("androidx.test:core:1.2.0") +} diff --git a/packages/video_player_android/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/video_player_android/example/android/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..4a647536 --- /dev/null +++ b/packages/video_player_android/example/android/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 00000000..bc81811a --- /dev/null +++ b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,21 @@ +// 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/* + * Annotation to aid repository tooling in determining if a test is + * a native java unit test or a java class with a dart integration. + * + * See: https://github.com/flutter/flutter/blob/master/docs/ecosystem/testing/Plugin-Tests.md#enabling-android-ui-tests + * for more information. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java new file mode 100644 index 00000000..0f4365ee --- /dev/null +++ b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java @@ -0,0 +1,19 @@ +// 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. + +package io.flutter.plugins.videoplayerexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +public class FlutterActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/VideoPlayerUITest.java b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/VideoPlayerUITest.java new file mode 100644 index 00000000..6ea5a988 --- /dev/null +++ b/packages/video_player_android/example/android/app/src/androidTest/java/io/flutter/plugins/videoplayerexample/VideoPlayerUITest.java @@ -0,0 +1,73 @@ +// 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. + +package io.flutter.plugins.videoplayerexample; + +import static androidx.test.espresso.flutter.EspressoFlutter.WidgetInteraction; +import static androidx.test.espresso.flutter.EspressoFlutter.onFlutterWidget; +import static androidx.test.espresso.flutter.action.FlutterActions.click; +import static androidx.test.espresso.flutter.assertion.FlutterAssertions.matches; +import static androidx.test.espresso.flutter.matcher.FlutterMatchers.isExisting; +import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withText; +import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withValueKey; + +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class VideoPlayerUITest { + @Rule + public ActivityScenarioRule activityRule = + new ActivityScenarioRule<>(DriverExtensionActivity.class); + + @Test + @Ignore("Doesn't run in Firebase Test Lab: https://github.com/flutter/flutter/issues/94748") + public void playVideo() { + WidgetInteraction remoteTab = onFlutterWidget(withText("Remote")); + remoteTab.check(matches(isExisting())); + + for (String tabName : new String[] {"Platform view", "Texture view"}) { + WidgetInteraction viewTypeTab = onFlutterWidget(withText(tabName)); + viewTypeTab.check(matches(isExisting())); + viewTypeTab.perform(click()); + + WidgetInteraction playButton = onFlutterWidget(withValueKey("Play")); + playButton.check(matches(isExisting())); + playButton.perform(click()); + + WidgetInteraction playbackSpeed1x = onFlutterWidget(withText("1.0x")); + playbackSpeed1x.check(matches(isExisting())); + playbackSpeed1x.perform(click()); + + WidgetInteraction playbackSpeed5xButton = onFlutterWidget(withText("5.0x")); + playbackSpeed5xButton.check(matches(isExisting())); + playbackSpeed5xButton.perform(click()); + + WidgetInteraction playbackSpeed5x = onFlutterWidget(withText("5.0x")); + playbackSpeed5x.check(matches(isExisting())); + } + + for (String[] tabData : + new String[][] {{"Asset", "With assets mp4"}, {"Remote", "With remote mp4"}}) { + String tabName = tabData[0]; + String videoDescription = tabData[1]; + WidgetInteraction tab = onFlutterWidget(withText(tabName)); + WidgetInteraction tabDescription = onFlutterWidget(withText(videoDescription)); + tab.check(matches(isExisting())); + + // TODO(FirentisTFW): Assert that testDescription is not visible before we tap on tab. + // This should be done once the Espresso API allows us to perform such an assertion. See + // https://github.com/flutter/flutter/issues/160599 + + tab.perform(click()); + + tab.check(matches(isExisting())); + tabDescription.check(matches(isExisting())); + } + } +} diff --git a/packages/video_player_android/example/android/app/src/main/java/io/flutter/plugins/videoplayerexample/DriverExtensionActivity.java b/packages/video_player_android/example/android/app/src/main/java/io/flutter/plugins/videoplayerexample/DriverExtensionActivity.java new file mode 100644 index 00000000..a379692d --- /dev/null +++ b/packages/video_player_android/example/android/app/src/main/java/io/flutter/plugins/videoplayerexample/DriverExtensionActivity.java @@ -0,0 +1,10 @@ +// 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. + +package io.flutter.plugins.videoplayerexample; + +import io.flutter.embedding.android.FlutterActivity; + +/** Test Activity that sets the name of the Dart method entrypoint in the manifest. */ +public class DriverExtensionActivity extends FlutterActivity {} diff --git a/packages/video_player_android/example/android/app/src/main/res/drawable/launch_background.xml b/packages/video_player_android/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/packages/video_player_android/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/video_player_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/video_player_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/packages/video_player_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/video_player_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/video_player_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/packages/video_player_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/video_player_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/video_player_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/packages/video_player_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/video_player_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/video_player_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/packages/video_player_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/video_player_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/video_player_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/packages/video_player_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/video_player_android/example/android/app/src/main/res/values/styles.xml b/packages/video_player_android/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..5691c756 --- /dev/null +++ b/packages/video_player_android/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/packages/video_player_android/example/android/app/src/main/res/xml/network_security_config.xml b/packages/video_player_android/example/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..37ad2d39 --- /dev/null +++ b/packages/video_player_android/example/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + www.sample-videos.com + 184.72.239.149 + + 127.0.0.1 + + \ No newline at end of file diff --git a/packages/video_player_android/example/android/app/src/test/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java b/packages/video_player_android/example/android/app/src/test/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java new file mode 100644 index 00000000..af32145f --- /dev/null +++ b/packages/video_player_android/example/android/app/src/test/java/io/flutter/plugins/videoplayerexample/FlutterActivityTest.java @@ -0,0 +1,56 @@ +// 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. + +package io.flutter.plugins.videoplayerexample; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.platform.PlatformViewRegistry; +import io.flutter.plugins.videoplayer.VideoPlayerPlugin; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class FlutterActivityTest { + AutoCloseable mockCloseable; + @Mock FlutterPlugin.FlutterPluginBinding flutterPluginBinding; + + @Before + public void before() { + mockCloseable = MockitoAnnotations.openMocks(this); + } + + @After + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @Test + public void disposeAllPlayers() { + when(flutterPluginBinding.getApplicationContext()).thenReturn(mock(Context.class)); + when(flutterPluginBinding.getBinaryMessenger()).thenReturn(mock(BinaryMessenger.class)); + when(flutterPluginBinding.getPlatformViewRegistry()) + .thenReturn(mock(PlatformViewRegistry.class)); + + VideoPlayerPlugin videoPlayerPlugin = spy(new VideoPlayerPlugin()); + + videoPlayerPlugin.onAttachedToEngine(flutterPluginBinding); + videoPlayerPlugin.onDetachedFromEngine(flutterPluginBinding); + verify(videoPlayerPlugin, times(1)).onDestroy(); + } +} diff --git a/packages/video_player_android/example/android/build.gradle b/packages/video_player_android/example/android/build.gradle new file mode 100644 index 00000000..f380df72 --- /dev/null +++ b/packages/video_player_android/example/android/build.gradle @@ -0,0 +1,59 @@ +allprojects { + repositories { + // See https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure for more info. + def artifactRepoKey = 'ARTIFACT_HUB_REPOSITORY' + if (System.getenv().containsKey(artifactRepoKey)) { + println "Using artifact hub" + maven { url System.getenv(artifactRepoKey) } + } + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} + +// Build the plugin project with warnings enabled. This is here rather than +// in the plugin itself to avoid breaking clients that have different +// warnings (e.g., deprecation warnings from a newer SDK than this project +// builds with). +gradle.projectsEvaluated { + project(":video_player_android") { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:all" << "-Werror" + + // Workaround for several warnings when building + // that the above turns into errors, coming from + // org.checkerframework.checker.nullness.qual and + // com.google.errorprone.annotations: + // + // warning: Cannot find annotation method 'value()' in type + // 'EnsuresNonNull': class file for + // org.checkerframework.checker.nullness.qual.EnsuresNonNull not found + // + // warning: Cannot find annotation method 'replacement()' in type + // 'InlineMe': class file for + // com.google.errorprone.annotations.InlineMe not found + // + // The dependency version are taken from: + // https://github.com/google/ExoPlayer/blob/r2.18.1/constants.gradle + // + // For future reference the dependencies are excluded here: + // https://github.com/google/ExoPlayer/blob/r2.18.1/library/common/build.gradle#L33-L34 + dependencies { + implementation("org.checkerframework:checker-qual:3.13.0") + implementation("com.google.errorprone:error_prone_annotations:2.10.0") + } + } + } +} diff --git a/packages/video_player_android/example/android/gradle.properties b/packages/video_player_android/example/android/gradle.properties new file mode 100644 index 00000000..da87b2c8 --- /dev/null +++ b/packages/video_player_android/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true diff --git a/packages/video_player_android/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/video_player_android/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c24d16c7 --- /dev/null +++ b/packages/video_player_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip + diff --git a/packages/video_player_android/example/android/settings.gradle b/packages/video_player_android/example/android/settings.gradle new file mode 100644 index 00000000..b542cca3 --- /dev/null +++ b/packages/video_player_android/example/android/settings.gradle @@ -0,0 +1,27 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +// See https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure for more info. +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "com.google.cloud.artifactregistry.gradle-plugin" version "2.2.1" +} + +include ":app" diff --git a/packages/video_player_android/example/assets/Butterfly-209.mp4 b/packages/video_player_android/example/assets/Butterfly-209.mp4 new file mode 100644 index 00000000..c8489799 Binary files /dev/null and b/packages/video_player_android/example/assets/Butterfly-209.mp4 differ diff --git a/packages/video_player_android/example/assets/flutter-mark-square-64.png b/packages/video_player_android/example/assets/flutter-mark-square-64.png new file mode 100644 index 00000000..56f22d5b Binary files /dev/null and b/packages/video_player_android/example/assets/flutter-mark-square-64.png differ diff --git a/packages/video_player_android/example/pubspec.yaml b/packages/video_player_android/example/pubspec.yaml new file mode 100644 index 00000000..07c5b497 --- /dev/null +++ b/packages/video_player_android/example/pubspec.yaml @@ -0,0 +1,36 @@ +name: video_player_example +description: Demonstrates how to use the video_player plugin. +publish_to: none + +environment: + sdk: ^3.9.0 + flutter: ">=3.35.0" + +dependencies: + flutter: + sdk: flutter + flutter_driver: + sdk: flutter + video_player_android: + # When depending on this package from a real application you should use: + # video_player_android: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + video_player_platform_interface: ^6.6.0 + +dev_dependencies: + espresso: ^0.4.0 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + path_provider: ^2.0.6 + test: any + +flutter: + uses-material-design: true + assets: + - assets/flutter-mark-square-64.png + - assets/Butterfly-209.mp4 diff --git a/packages/video_player_android/pigeons/copyright.txt b/packages/video_player_android/pigeons/copyright.txt new file mode 100644 index 00000000..07e5f859 --- /dev/null +++ b/packages/video_player_android/pigeons/copyright.txt @@ -0,0 +1,3 @@ +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. diff --git a/packages/video_player_android/pubspec.yaml b/packages/video_player_android/pubspec.yaml new file mode 100644 index 00000000..4461ddd7 --- /dev/null +++ b/packages/video_player_android/pubspec.yaml @@ -0,0 +1,34 @@ +name: video_player_android +description: Android implementation of the video_player plugin. +repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 +version: 2.9.5 + +environment: + sdk: ^3.9.0 + flutter: ">=3.35.0" + +flutter: + plugin: + implements: video_player + platforms: + android: + dartPluginClass: AndroidVideoPlayer + package: io.flutter.plugins.videoplayer + pluginClass: VideoPlayerPlugin + +dependencies: + flutter: + sdk: flutter + video_player_platform_interface: ^6.6.0 + +dev_dependencies: + build_runner: ^2.3.3 + flutter_test: + sdk: flutter + mockito: ^5.4.4 + pigeon: ^26.1.5 + +topics: + - video + - video-player diff --git a/pubspec.lock b/pubspec.lock index 251afdf1..b71249cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.9" + version: "4.3.0" args: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: "direct main" description: name: bloc - sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 url: "https://pub.flutter-io.cn" source: hosted - version: "9.2.0" + version: "9.2.1" bluez: dependency: transitive description: @@ -77,34 +77,34 @@ packages: dependency: transitive description: name: build - sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.5" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.flutter-io.cn" source: hosted - version: "4.1.1" + version: "4.1.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.flutter-io.cn" source: hosted - version: "2.13.1" + version: "2.15.1" built_collection: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.flutter-io.cn" source: hosted - version: "8.12.5" + version: "8.13.0" cc_ui_kit: dependency: "direct main" description: @@ -164,18 +164,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.11.1" + version: "1.2.1" collection: dependency: transitive description: @@ -185,7 +177,7 @@ packages: source: hosted version: "1.19.1" connectivity_plus: - dependency: "direct dev" + dependency: "direct main" description: name: connectivity_plus sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec @@ -212,12 +204,12 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.5+2" + version: "0.3.5+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -260,10 +252,10 @@ packages: dependency: transitive description: name: dart_webrtc - sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 + sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716" url: "https://pub.flutter-io.cn" source: hosted - version: "1.8.1" + version: "1.8.2" dartx: dependency: transitive description: @@ -276,10 +268,10 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.12" + version: "0.7.15" device_info_plus: dependency: "direct main" description: @@ -300,26 +292,26 @@ packages: dependency: "direct main" description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" url: "https://pub.flutter-io.cn" source: hosted - version: "5.9.2" + version: "5.11.1" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.2.2" equatable: dependency: "direct main" description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.8" + version: "2.1.0" event_bus: dependency: transitive description: @@ -356,18 +348,18 @@ packages: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -380,10 +372,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -417,10 +409,10 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: "2ff21c8aa1a6798f13519782627759f6f1703afac11fc1d25877f4a45bfd426d" + sha256: "5389f2b85305d0e03b025de7a92ec86137440b5108744f1017645ff67c5f2dcc" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.11" + version: "2.3.12" flutter_blue_plus_android: dependency: transitive description: @@ -433,10 +425,10 @@ packages: dependency: transitive description: name: flutter_blue_plus_darwin - sha256: bf41a4a07978b4c86a344c0c6e7388ff69f2b442a8d943b8da6d7f01a5c435bc + sha256: ce55b40e751da0d9a74bf14db3924d2553b7e67ca0270ea9260f2d46a44ead3a url: "https://pub.flutter-io.cn" source: hosted - version: "9.0.3" + version: "9.0.4" flutter_blue_plus_linux: dependency: transitive description: @@ -518,26 +510,26 @@ packages: dependency: "direct main" description: name: flutter_patcher - sha256: e76de4c6469b46730d0b1d7dc7a9852fc09ff175c019739d5ad4e431a961d11a + sha256: "2dba7dce8cedea6d7cb70d016c9bb63ce1216e3cf8329fa64f43b791d28237c3" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.2" + version: "0.1.4" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.4" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -552,10 +544,10 @@ packages: dependency: "direct main" description: name: flutter_webrtc - sha256: c7b0a67ca2c878575fc5c146d801cd874f58f5f1ef5fa6e8eb0c93d413beb948 + sha256: "381e05c120caf2f1ee1accd806baad22b33802f36c74d8ea5e43a5800ce6380c" url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.1" + version: "1.6.2+hotfix.1" fpdart: dependency: "direct main" description: @@ -584,26 +576,26 @@ packages: dependency: transitive description: name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.13" + version: "2.3.14" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + sha256: "94db8255dc183d268765df682580440617ca35877fc82cacb5420ad03b86198d" url: "https://pub.flutter-io.cn" source: hosted - version: "4.2.6" + version: "4.3.0" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" url: "https://pub.flutter-io.cn" source: hosted - version: "4.1.3" + version: "4.1.4" geolocator_windows: dependency: transitive description: @@ -624,26 +616,26 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.3" + version: "2.2.0" go_router: dependency: "direct main" description: name: go_router - sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea url: "https://pub.flutter-io.cn" source: hosted - version: "17.1.0" + version: "17.5.0" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e + sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d url: "https://pub.flutter-io.cn" source: hosted - version: "8.0.2" + version: "8.2.1" google_nav_bar: dependency: "direct main" description: @@ -672,18 +664,18 @@ packages: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.2" + version: "2.0.2" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.flutter-io.cn" source: hosted - version: "0.15.6" + version: "0.15.7" http: dependency: "direct main" description: @@ -720,26 +712,26 @@ packages: dependency: "direct main" description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.flutter-io.cn" source: hosted - version: "4.8.0" + version: "4.10.1" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.1" + version: "1.2.3" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd" + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f url: "https://pub.flutter-io.cn" source: hosted - version: "0.8.13+15" + version: "0.8.13+17" image_picker_for_web: dependency: transitive description: @@ -752,10 +744,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae url: "https://pub.flutter-io.cn" source: hosted - version: "0.8.13+6" + version: "0.8.13+7" image_picker_linux: dependency: transitive description: @@ -800,18 +792,18 @@ packages: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.5" + version: "1.1.0" iris_method_channel: dependency: transitive description: name: iris_method_channel - sha256: "114bbe541369add8dd0727858e7df5764f375e3fb88374ad487301733fddb57f" + sha256: "9ba4f428bc944ef2a2eb04495f3e184d598984c1724991748a293172c6d1e43b" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.5" + version: "2.2.7" isar_community: dependency: "direct main" description: @@ -856,10 +848,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.flutter-io.cn" source: hosted - version: "4.11.0" + version: "4.12.0" latlong2: dependency: "direct main" description: @@ -912,10 +904,10 @@ packages: dependency: "direct main" description: name: logger - sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" url: "https://pub.flutter-io.cn" source: hosted - version: "2.7.0" + version: "2.8.0" logging: dependency: transitive description: @@ -976,10 +968,10 @@ packages: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.0" + version: "2.1.0" mobile_scanner: dependency: "direct main" description: @@ -996,14 +988,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "10.11.11" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.17.6" nested: dependency: transitive description: @@ -1024,10 +1008,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.flutter-io.cn" source: hosted - version: "9.3.0" + version: "9.5.0" open_file: dependency: "direct main" description: @@ -1104,10 +1088,10 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.flutter-io.cn" source: hosted - version: "8.3.1" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1136,10 +1120,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -1160,18 +1144,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -1184,10 +1168,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.1" + version: "12.0.3" permission_handler_android: dependency: transitive description: @@ -1200,34 +1184,34 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 url: "https://pub.flutter-io.cn" source: hosted - version: "9.4.7" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 url: "https://pub.flutter-io.cn" source: hosted - version: "4.3.0" + version: "4.4.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.1" + version: "0.2.2" petitparser: dependency: transitive description: @@ -1264,18 +1248,18 @@ packages: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.flutter-io.cn" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.flutter-io.cn" source: hosted - version: "6.5.0" + version: "6.5.2" proj4dart: dependency: transitive description: @@ -1296,18 +1280,26 @@ packages: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.flutter-io.cn" source: hosted - version: "1.5.0" + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" scroll_to_index: dependency: transitive description: @@ -1320,18 +1312,18 @@ packages: dependency: transitive description: name: sentry - sha256: "288aee3d35f252ac0dc3a4b0accbbe7212fa2867604027f2cc5bc65334afd743" + sha256: bf80a571e370aad3f68b5c26d43f31cc230a084c250fb3138a83da6032ba0c01 url: "https://pub.flutter-io.cn" source: hosted - version: "9.16.0" + version: "9.30.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: f9e87d5895cc437902aa2b081727ee7e46524fe7cc2e1910f535480a3eeb8bed + sha256: da36fbb1581c0f66a0c8fc76f8aadb62d9ea567ba950c97eb326e959482c342a url: "https://pub.flutter-io.cn" source: hosted - version: "9.16.0" + version: "9.30.0" shared_preferences: dependency: "direct main" description: @@ -1352,10 +1344,10 @@ packages: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -1413,10 +1405,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.flutter-io.cn" source: hosted - version: "4.2.2" + version: "4.2.4" source_span: dependency: transitive description: @@ -1445,10 +1437,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1533,34 +1525,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.29" + version: "6.3.30" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.flutter-io.cn" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1573,34 +1565,41 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.2" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.5" + version: "3.1.6" + usb_camera_android: + dependency: "direct main" + description: + path: "packages/usb_camera_android" + relative: true + source: path + version: "0.1.0" uuid: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.flutter-io.cn" source: hosted - version: "4.5.3" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.21" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1613,10 +1612,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1637,10 +1636,10 @@ packages: dependency: transitive description: name: vibration_platform_interface - sha256: "4134fbfcd427b59a7a91f8733292e4e9b29a7f1e8224ff0d80f5745fbf0743c6" + sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.1" + version: "0.1.2" video_player: dependency: "direct main" description: @@ -1650,29 +1649,28 @@ packages: source: hosted version: "2.11.1" video_player_android: - dependency: transitive + dependency: "direct overridden" description: - name: video_player_android - sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" - url: "https://pub.flutter-io.cn" - source: hosted + path: "packages/video_player_android" + relative: true + source: path version: "2.9.5" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + sha256: "2fc56e537324a19eb649fca991aa308ac961b5406292876fa657b062de461fd8" url: "https://pub.flutter-io.cn" source: hosted - version: "2.9.7" + version: "2.12.0" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" url: "https://pub.flutter-io.cn" source: hosted - version: "6.7.0" + version: "6.9.0" video_player_web: dependency: transitive description: @@ -1693,18 +1691,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.flutter-io.cn" source: hosted - version: "15.0.2" + version: "15.3.0" volc_engine_rtc: dependency: "direct main" description: name: volc_engine_rtc - sha256: b14448d7dd19a53abf2da9b91606df4bef91bac8491cff61b43aaa0de67f0006 + sha256: b254a694ecf3f2c5de0e63bd86b79e809b0b015932df27f7c1ce6d70768ba7d6 url: "https://pub.flutter-io.cn" source: hosted - version: "3.60.4" + version: "3.60.6" watcher: dependency: transitive description: @@ -1749,18 +1747,18 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 url: "https://pub.flutter-io.cn" source: hosted - version: "4.13.1" + version: "4.14.1" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 url: "https://pub.flutter-io.cn" source: hosted - version: "4.10.15" + version: "4.12.0" webview_flutter_platform_interface: dependency: transitive description: @@ -1773,10 +1771,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" url: "https://pub.flutter-io.cn" source: hosted - version: "3.24.2" + version: "3.25.1" win32: dependency: transitive description: @@ -1829,10 +1827,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: dart: ">=3.10.3 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 6e9a56b9..9043f3f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,9 @@ dependencies: cc_ui_kit: path: ./packages/cc_ui_kit + usb_camera_android: + path: ./packages/usb_camera_android + # ===== 状态管理(BLoC)===== flutter_bloc: ^9.1.1 bloc: ^9.2.0 @@ -145,6 +148,15 @@ dependencies: open_file: ^3.3.2 # 打开文件(安装 APK) mqtt_client: ^10.11.11 + # ===== 本地摄像头:ONVIF 发现 + RTSP 播放 ===== + crypto: ^3.0.3 + connectivity_plus: ^6.0.3 + +# 本地覆盖 video_player_android:注入零缓冲 LoadControl (bufferForPlaybackMs=0) 实现直播低延迟 +dependency_overrides: + video_player_android: + path: packages/video_player_android + dev_dependencies: flutter_test: sdk: flutter @@ -157,7 +169,7 @@ dev_dependencies: flutter_lints: ^6.0.0 dart_ping: ^9.0.0 - connectivity_plus: ^6.0.3 + # connectivity_plus 已提升至 dependencies(本地摄像头断网监听使用) # 自动生成 ".g.dart" 文件 # 只有dev运行命令时才需要,用户运行 App 时不需要 isar_community_generator: ^3.3.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3c0d642e..71a40e41 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AgoraRtcEnginePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AgoraRtcEnginePlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterBluePlusPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 639fd277..09e2268e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST agora_rtc_engine + connectivity_plus file_selector_windows flutter_blue_plus_winrt flutter_webrtc