diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index efaaff5..2fc131e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,9 @@ + + + @@ -57,4 +60,4 @@ - + \ No newline at end of file diff --git a/lib/core/utils/util_search_nasIp.dart b/lib/core/utils/util_search_nasIp.dart new file mode 100644 index 0000000..adf2e2b --- /dev/null +++ b/lib/core/utils/util_search_nasIp.dart @@ -0,0 +1,129 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:network_info_plus/network_info_plus.dart'; + +/// 局域网 NAS IP 探测(结果仅存内存静态字段)。 +class UtilSearchNasIp { + UtilSearchNasIp._(); + + static const String _defaultPreUrl = 'http://192.168.1.'; + static const String _probePath = + ':5666/sac/rpcproxy/v1/new-user-guide/status'; + static const Duration _timeout = Duration(seconds: 1); + + /// 命中的 NAS IPv4(仅内存,进程内有效)。 + static String? nasIp; + + /// 并发扫同网段 1~255,超时 1s;首个有 HTTP 响应者写入 [nasIp] 并返回。 + static Future discover() async { + if (nasIp != null && nasIp!.isNotEmpty) { + return nasIp; + } + + final preUrl = await _resolvePreUrl(); + final dio = Dio( + BaseOptions( + connectTimeout: _timeout, + receiveTimeout: _timeout, + sendTimeout: _timeout, + validateStatus: (_) => true, + ), + ); + final cancelToken = CancelToken(); + + try { + await Future.wait([ + for (var host = 1; host <= 255; host++) + _probeHost( + dio: dio, + cancelToken: cancelToken, + preUrl: preUrl, + host: host, + ), + ]); + return nasIp; + } finally { + dio.close(force: true); + } + } + + /// 由本机 Wi‑Fi IPv4 推导 `http://a.b.c.`,失败则用默认网段。 + static Future _resolvePreUrl() async { + try { + final wifiIp = await NetworkInfo().getWifiIP(); + if (wifiIp == null || wifiIp.isEmpty) return _defaultPreUrl; + final parts = wifiIp.split('.'); + if (parts.length != 4) return _defaultPreUrl; + return 'http://${parts[0]}.${parts[1]}.${parts[2]}.'; + } catch (_) { + return _defaultPreUrl; + } + } + + /// 探测单个主机;任意 HTTP 响应视为命中。 + static Future _probeHost({ + required Dio dio, + required CancelToken cancelToken, + required String preUrl, + required int host, + }) async { + if (cancelToken.isCancelled || nasIp != null) return; + + final target = '$preUrl$host$_probePath'; + try { + await dio.get(target, cancelToken: cancelToken); + _onHit(preUrl, host, cancelToken); + } on DioException catch (error) { + if (error.type == DioExceptionType.cancel) return; + // 带 response 的 DioException 也表示已连通 + if (error.response != null) { + _onHit(preUrl, host, cancelToken); + } + } catch (_) { + // 超时 / 连接失败:未命中 + } + } + + /// 记录命中:写 [nasIp]、打印并取消其余请求。 + static void _onHit(String preUrl, int host, CancelToken cancelToken) { + if (nasIp != null) return; + final ip = '${_hostPrefix(preUrl)}$host'; + nasIp = ip; + debugPrint('NAS IP: $ip'); + if (!cancelToken.isCancelled) { + cancelToken.cancel('nas-found'); + } + } + + /// `http://192.168.1.` → `192.168.1.` + static String _hostPrefix(String preUrl) { + return preUrl.replaceFirst(RegExp(r'^https?://'), ''); + } +} + +// --------------------------------------------------------------------------- +// 调用案例 +// --------------------------------------------------------------------------- +// +// 1) Auth 页静默探测(不阻塞登录): +// +// import 'dart:async'; +// import 'package:recording_tool/core/utils/util_getNasIp.dart'; +// +// WidgetsBinding.instance.addPostFrameCallback((_) { +// unawaited(UtilGetNasIp.discover()); +// }); +// +// 2) 业务侧读取已命中 IP: +// +// final ip = UtilGetNasIp.nasIp; +// if (ip != null) { +// final base = 'http://$ip:5666'; +// // 使用 base 访问 NAS... +// } +// +// 3) 主动等待探测结果(少用,会阻塞当前异步流程): +// +// final ip = await UtilGetNasIp.discover(); +// debugPrint('result: $ip'); +// diff --git a/lib/features/auth/pages/page_auth.dart b/lib/features/auth/pages/page_auth.dart index fd36026..4b967da 100644 --- a/lib/features/auth/pages/page_auth.dart +++ b/lib/features/auth/pages/page_auth.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:recording_tool/app/router/app_navigator.dart'; import 'package:recording_tool/core/cache/app_storage.dart'; import 'package:recording_tool/core/cache/storage_keys.dart'; +import 'package:recording_tool/core/utils/util_search_nasIp.dart'; import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart'; import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart'; import 'package:recording_tool/shared/widgets/widgets.dart'; @@ -25,6 +28,9 @@ class _AuthPageWidgetState extends ConsumerState { _controller?.text = '555'; WidgetsBinding.instance.addPostFrameCallback((_) async { + // 静默探测 NAS,不阻塞登录 / 自动跳转 + unawaited(UtilSearchNasIp.discover()); + final token = AppStorage.getString(StorageKeys.authToken); if (token?.isNotEmpty ?? false) { AppNavigator.push(const ScanQrCodePage()); diff --git a/lib/features/events/model/model_event_info.dart b/lib/features/events/model/model_event_info.dart index f5f59ce..73d8bc9 100644 --- a/lib/features/events/model/model_event_info.dart +++ b/lib/features/events/model/model_event_info.dart @@ -16,14 +16,21 @@ class StreamKeyReq { required this.eventId, required this.itemId, required this.userId, + required this.scheduleId, }); final String eventId; final String itemId; final String userId; + final String scheduleId; Map toFormDataMap() { - return {'eventId': eventId, 'itemId': itemId, 'userId': userId}; + return { + 'eventId': eventId, + 'itemId': itemId, + 'userId': userId, + 'scheduleId': scheduleId, + }; } } @@ -136,8 +143,7 @@ class EventRegistrationItem { final String playerName; final String playerPhone; - String get scheduleTime => - _formatScheduleTime(matchStartTime, matchEndTime); + String get scheduleTime => _formatScheduleTime(matchStartTime, matchEndTime); String? get statusLabel => completed ? '已完成' : null; diff --git a/lib/features/events/pages/page_event_info.dart b/lib/features/events/pages/page_event_info.dart index 620b093..198a36c 100644 --- a/lib/features/events/pages/page_event_info.dart +++ b/lib/features/events/pages/page_event_info.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:recording_tool/app/router/app_navigator.dart'; +import 'package:recording_tool/core/utils/util_search_nasIp.dart'; import 'package:recording_tool/features/events/model/model_event_info.dart'; import 'package:recording_tool/features/events/state/state_event_info.dart'; import 'package:recording_tool/features/events/view_model/view_model_event_info.dart'; -import 'package:recording_tool/shared/widgets/app_webview.dart'; +import 'package:recording_tool/features/recording/model/model_recording_context.dart'; +import 'package:recording_tool/features/recording/pages/page_record.dart'; import 'package:recording_tool/shared/widgets/widgets.dart'; class EventInfoPage extends ConsumerStatefulWidget { @@ -24,16 +26,33 @@ class _EventInfoPageState extends ConsumerState { Future onItemTap(EventRegistrationItem item) async { // debugPrint('item tapped: ${item.itemName}'); - // await ref.read(eventInfoProvider.notifier).requestStreamKey(item); + final streamKey = await ref + .read(eventInfoProvider.notifier) + .requestStreamKey(item); + if (streamKey == null || streamKey.isEmpty) { + return; + } if (!mounted) return; - // final profile = ref.read(eventInfoProvider).profile; - /// 个人赛 + /// 获取局域网 IP + // final ip = + // await ref.read(eventInfoProvider.notifier).getLocalIP() ?? 'sheling'; + final ip = UtilSearchNasIp.nasIp ?? 'sheling'; + // final ip = '192.168.1.245'; + // final streamUrl = 'rtmp://$ip:19090/$streamKey'; + final streamUrl = 'rtmp://$ip:19090/测试团队赛录分缓存/空中足球赛/初中组/林培伦vs周白芷'; AppNavigator.push( - WebviewPage( - url: 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry', - eventRegistrationItem: item, - playerId: widget.playerId, + RecordingPage( + recordingContext: RecordingContext( + eventTitle: item.eventName, + matchName: item.itemName, + group: item.groupName, + venue: item.matchPlace, + time: item.scheduleTime, + playerName: item.playerName, + playerPhone: item.playerPhone, + ), + streamUrl: streamUrl, ), ); } diff --git a/lib/features/events/view_model/view_model_event_info.dart b/lib/features/events/view_model/view_model_event_info.dart index 997a5cf..e40df22 100644 --- a/lib/features/events/view_model/view_model_event_info.dart +++ b/lib/features/events/view_model/view_model_event_info.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; -import 'package:recording_tool/core/network/api_exception.dart'; +import 'package:network_info_plus/network_info_plus.dart'; import 'package:recording_tool/features/events/model/model_event_info.dart'; import 'package:recording_tool/features/events/server/server_events.dart'; import 'package:recording_tool/features/events/state/state_event_info.dart'; @@ -20,10 +20,6 @@ class EventInfoViewModel extends StateNotifier { status: '', ); - static const _fallbackEventId = ''; - static const _fallbackItemId = ''; - static const _fallbackStreamUserId = ''; - final Ref _ref; Future loadRegistrationList({ @@ -50,28 +46,43 @@ class EventInfoViewModel extends StateNotifier { } } - Future requestStreamKey(EventRegistrationItem item) async { + /// 获取局域网 IP + + Future getLocalIP() async { + final info = NetworkInfo(); + try { + // 获取当前连接 Wi-Fi 分配的局域网 IPv4 地址(例如 192.168.1.105) + String? wifiIP = await info.getWifiIP(); + return wifiIP; + } catch (e) { + print("获取局域网 IP 失败: $e"); + return null; + } + } + + Future requestStreamKey(EventRegistrationItem item) async { state = state.copyWith(isRequestingStreamKey: true); final req = StreamKeyReq( - eventId: item.eventId.isNotEmpty ? item.eventId : _fallbackEventId, - itemId: item.itemId.isNotEmpty ? item.itemId : _fallbackItemId, - userId: item.userId.isNotEmpty ? item.userId : _fallbackStreamUserId, + eventId: item.eventId, + itemId: item.itemId, + userId: item.userId, + scheduleId: item.scheduleId, ); try { final response = await _ref .read(eventsServerProvider) .fetchStreamKey(req); - debugPrint('推流 Key 接口响应: $response'); + debugPrint('推流 Key 接口响应: ${response.rawData['streamKey']}'); state = state.copyWith(isRequestingStreamKey: false); - } on ApiException catch (error) { - debugPrint('推流 Key 接口请求失败: ${error.message}'); - state = state.copyWith(isRequestingStreamKey: false); - AppToast.show(error.message); + + return response.rawData['streamKey']; + // return response['streamKey']; } catch (error) { debugPrint('推流 Key 接口请求失败: $error'); state = state.copyWith(isRequestingStreamKey: false); - AppToast.show('推流 Key 获取失败'); + AppToast.show('获取选手信息失败'); + return null; } } } diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 8573553..1b7a772 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:apivideo_live_stream/apivideo_live_stream.dart'; @@ -20,7 +21,6 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_hud.d import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart'; -import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart'; import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart'; import 'package:recording_tool/shared/widgets/widgets.dart'; @@ -57,7 +57,7 @@ class _RecordingPageState extends ConsumerState { _streamController = ApiVideoLiveStreamController( initialAudioConfig: AudioConfig(bitrate: 128000), initialVideoConfig: VideoConfig.withDefaultBitrate( - resolution: Resolution.RESOLUTION_1080, + resolution: Resolution.RESOLUTION_720, fps: 30, ), onConnectionSuccess: () => {debugPrint('推流成功')}, @@ -314,7 +314,7 @@ class _RecordingPageState extends ConsumerState { /// 返回扫码页,准备新一轮录制。 void _recordNewRound() { - AppNavigator.pushAndRemoveUntil(const ScanQrCodePage()); + AppNavigator.pop(context: context); } /// 推流结束后按需弹出完成对话框 @@ -337,38 +337,53 @@ class _RecordingPageState extends ConsumerState { ); } - /// 退出沉浸式并释放录制会话 + /// 退出沉浸式并释放录制会话。 + /// 先恢复系统栏,再错开一帧释放编码器,减轻返回动画卡顿。 Future _exitRecordingMode() async { - if (!_immersiveApplied) return; - await ref.read(recordingViewModelProvider.notifier).teardown(); + await _restoreSystemUiIfNeeded(); + if (mounted) { + await ref.read(recordingViewModelProvider.notifier).teardown(); + } + + // 让 pop 动画先跑起来,再做 MediaCodec 释放。 + await Future.delayed(Duration.zero); await _disposeStreamController(); + } + + Future _restoreSystemUiIfNeeded() async { + if (!_immersiveApplied) return; + _immersiveApplied = false; await SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: SystemUiOverlay.values, ); await RecordingPlatform.setImmersiveMode(enabled: false); - _immersiveApplied = false; } @override - /// 页面销毁时恢复系统 UI + /// 页面销毁时兜底恢复系统 UI 并释放推流控制器 void dispose() { if (_immersiveApplied) { + _immersiveApplied = false; SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: SystemUiOverlay.values, ); - RecordingPlatform.setImmersiveMode(enabled: false); + unawaited(RecordingPlatform.setImmersiveMode(enabled: false)); } - _disposeStreamController(); + unawaited(_disposeStreamController()); super.dispose(); } + /// 只 dispose 一次;原生 dispose 内部已 stopStream,避免 stop+dispose 双次停流。 Future _disposeStreamController() async { if (_controllerDisposed) return; _controllerDisposed = true; - await _streamController.stop(); - await _streamController.dispose(); + try { + await _streamController.dispose(); + } catch (error, stackTrace) { + debugPrint('释放推流控制器失败: $error\n$stackTrace'); + } } @override @@ -427,9 +442,10 @@ class _RecordingPopScope extends ConsumerWidget { return PopScope( canPop: !isRecording, - onPopInvokedWithResult: (didPop, result) async { + onPopInvokedWithResult: (didPop, result) { if (didPop) { - await onExitRecordingMode(); + // 不 await,避免把 MediaCodec 释放堵在 Pop 回调上。 + unawaited(onExitRecordingMode()); return; } if (isRecording) { diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 6ddfde5..cf687f3 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -280,9 +280,8 @@ class RecordingViewModel extends Notifier { _updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored)); } - /// 退出录制页时释放相机、勿扰和状态订阅。 + /// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。 Future teardown() async { - await RecordingPlatform.setImmersiveMode(enabled: false); await RecordingPlatform.disableDoNotDisturb(); _recordingStartedAt = null; _elapsedTimer?.cancel(); diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index b39a4b0..bf9ba63 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/app/router/app_navigator.dart'; import 'package:recording_tool/features/recording/widgets/record_content_transition.dart'; import 'package:recording_tool/gen/assets.gen.dart'; @@ -59,6 +60,14 @@ class RecordHeaderWidget extends StatelessWidget { ], ), ), + // 返回上一页 + IconButton( + onPressed: () => AppNavigator.pop(context: context), + icon: Icon(Icons.arrow_back, size: 32.r), + color: Colors.white, + padding: EdgeInsets.only(left: 16.w), + constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h), + ), ], ), ), diff --git a/plugins/apivideo_live_stream/android/.gradle/8.13/fileChanges/last-build.bin b/plugins/apivideo_live_stream/android/.gradle/8.13/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/plugins/apivideo_live_stream/android/.gradle/8.13/fileChanges/last-build.bin differ diff --git a/plugins/apivideo_live_stream/android/.gradle/8.13/fileHashes/fileHashes.lock b/plugins/apivideo_live_stream/android/.gradle/8.13/fileHashes/fileHashes.lock new file mode 100644 index 0000000..4c650b9 Binary files /dev/null and b/plugins/apivideo_live_stream/android/.gradle/8.13/fileHashes/fileHashes.lock differ diff --git a/plugins/apivideo_live_stream/android/.gradle/8.13/gc.properties b/plugins/apivideo_live_stream/android/.gradle/8.13/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 1c5cf3e..1c545ff 100644 Binary files a/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/cache.properties b/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/cache.properties index 7a41652..c53ef16 100644 --- a/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/cache.properties +++ b/plugins/apivideo_live_stream/android/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Mon Jul 13 17:53:35 CST 2026 +#Fri Jul 17 16:25:39 CST 2026 gradle.version=8.2 diff --git a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt index e93407c..c399bfc 100644 --- a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt +++ b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt @@ -175,8 +175,18 @@ class FlutterLiveStreamView( } fun dispose() { - stopStream() - streamer.stopPreview() + try { + stopStream() + } catch (e: Exception) { + android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e) + _isStreaming = false + } + try { + streamer.stopPreview() + } catch (e: Exception) { + android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e) + } + _isPreviewing = false flutterTexture.release() } @@ -195,14 +205,27 @@ class FlutterLiveStreamView( } fun stopStream() { + if (!_isStreaming && !streamer.isConnected) { + return + } val isConnected = streamer.isConnected - runBlocking { - streamer.stopStream() - streamer.disconnect() - if (isConnected) { - onDisconnected() + try { + runBlocking { + streamer.stopStream() + streamer.disconnect() + if (isConnected) { + onDisconnected() + } + _isStreaming = false } + } catch (e: Exception) { + android.util.Log.w("ApiVideoLiveStream", "stopStream failed", e) _isStreaming = false + try { + streamer.disconnect() + } catch (_: Exception) { + // ignore secondary disconnect failures + } } } diff --git a/pubspec.yaml b/pubspec.yaml index fa5f8b9..f950688 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: apivideo_live_stream: path: plugins/apivideo_live_stream jwt_decoder: ^2.0.1 + network_info_plus: ^8.2.1 dev_dependencies: flutter_test: