Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a7e74809 | ||
|
|
9dea31f76e | ||
|
|
17799af2e7 | ||
|
|
d4920e2b98 | ||
|
|
a19d9dfa6d | ||
|
|
934b3ea2fa | ||
|
|
36253aacbe | ||
|
|
a56a42c7c8 | ||
|
|
d29b4ed5bf | ||
|
|
b9c0d60f2a | ||
|
|
43b967d2f8 | ||
|
|
58de72ac92 | ||
|
|
bfc76f469f | ||
|
|
744e768d3a | ||
|
|
25a07cbd3b |
+1
-1
@@ -51,4 +51,4 @@ app.*.map.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
test/
|
||||
script
|
||||
script/
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
@@ -57,4 +60,4 @@
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
</manifest>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 251 KiB |
+1
-1
@@ -5,4 +5,4 @@ flutter build apk --release --split-per-abi
|
||||
|
||||
# echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "主裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "新增可视化请求插件"
|
||||
@@ -24,7 +24,7 @@ class AppConfig {
|
||||
static AppPackageInfo? packageInfo;
|
||||
|
||||
static const appName = 'SportsX裁判工作台';
|
||||
static const designSize = Size(640, 1024);
|
||||
static const designSize = Size(375, 812);
|
||||
|
||||
/// 主裁判计分 H5 链接
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -6,6 +8,7 @@ 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/device_utils.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/gen/assets.gen.dart';
|
||||
@@ -30,6 +33,9 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
_controller = TextEditingController(text: '');
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// 静默探测 NAS,不阻塞登录 / 自动跳转
|
||||
unawaited(UtilSearchNasIp.discover());
|
||||
|
||||
final token = AppStorage.getString(StorageKeys.authToken);
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
final success = await ref
|
||||
|
||||
@@ -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<String, dynamic> toFormDataMap() {
|
||||
return {'eventId': eventId, 'itemId': itemId, 'userId': userId};
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'itemId': itemId,
|
||||
'userId': userId,
|
||||
'scheduleId': scheduleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
|
||||
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.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/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/pages/page_record.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_webview.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
class EventInfoPage extends ConsumerStatefulWidget {
|
||||
@@ -29,36 +27,42 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
|
||||
}
|
||||
|
||||
Future<void> onItemTap(EventRegistrationItem item) async {
|
||||
if (!mounted) return;
|
||||
CompetitionTeamDetail? detail;
|
||||
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
|
||||
detail = await ref
|
||||
.read(competitionTeamsServerProvider)
|
||||
.fetchTeamDetail(
|
||||
itemId: item.itemId,
|
||||
eventId: item.eventId,
|
||||
scheduleId: item.scheduleId,
|
||||
opponentId: item.opponentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNavigator.push(
|
||||
buildEventRegistrationDestination(
|
||||
item: item,
|
||||
playerId: widget.playerId,
|
||||
detail: detail,
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
// debugPrint('item tapped: ${item.itemName}');
|
||||
final streamKey = await ref
|
||||
.read(eventInfoProvider.notifier)
|
||||
.requestStreamKey(item);
|
||||
if (streamKey == null || streamKey.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
/// 获取局域网 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(
|
||||
buildEventRegistrationDestination(
|
||||
item: item,
|
||||
playerId: widget.playerId,
|
||||
detail: null,
|
||||
RecordingPage(
|
||||
recordingContext: RecordingContext(
|
||||
eventTitle: item.eventName,
|
||||
matchName: item.itemName,
|
||||
group: item.groupName,
|
||||
venue: item.matchPlace,
|
||||
time: item.scheduleTime,
|
||||
playerName: item.playerName,
|
||||
playerPhone: item.playerPhone,
|
||||
isTeam: item.opponentId != '0',
|
||||
opponentName: item.opponentName,
|
||||
teamLeaderName: item.teamMembers.isNotEmpty
|
||||
? item.teamMembers
|
||||
.firstWhere((member) => member.isLeader)
|
||||
.name
|
||||
.trim()
|
||||
: '',
|
||||
),
|
||||
streamUrl: streamUrl,
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
@@ -131,20 +135,20 @@ class _TopGradientHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildEventRegistrationDestination({
|
||||
required EventRegistrationItem item,
|
||||
required String playerId,
|
||||
required CompetitionTeamDetail? detail,
|
||||
}) {
|
||||
if (detail != null) {
|
||||
return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
|
||||
}
|
||||
return WebviewPage(
|
||||
url: AppConfig.current.mainRefereeScoreH5Url,
|
||||
eventRegistrationItem: item,
|
||||
playerId: playerId,
|
||||
);
|
||||
}
|
||||
// Widget buildEventRegistrationDestination({
|
||||
// required EventRegistrationItem item,
|
||||
// required String playerId,
|
||||
// required CompetitionTeamDetail? detail,
|
||||
// }) {
|
||||
// if (detail != null) {
|
||||
// return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
|
||||
// }
|
||||
// return WebviewPage(
|
||||
// url: AppConfig.current.mainRefereeScoreH5Url,
|
||||
// eventRegistrationItem: item,
|
||||
// playerId: playerId,
|
||||
// );
|
||||
// }
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.onBack});
|
||||
@@ -486,9 +490,10 @@ String _formatTitle(EventRegistrationItem item) {
|
||||
}
|
||||
|
||||
String _formatVenue(EventRegistrationItem item, int scheduleIndex) {
|
||||
final place = item.matchPlace.trim();
|
||||
if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区';
|
||||
return '$place号场馆$scheduleIndex区';
|
||||
return item.matchPlace.trim();
|
||||
// final place = item.matchPlace.trim();
|
||||
// if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区';
|
||||
// return '$place号场馆$scheduleIndex区';
|
||||
}
|
||||
|
||||
String _formatScheduleTime(EventRegistrationItem item) {
|
||||
|
||||
@@ -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<EventInfoState> {
|
||||
status: '',
|
||||
);
|
||||
|
||||
static const _fallbackEventId = '';
|
||||
static const _fallbackItemId = '';
|
||||
static const _fallbackStreamUserId = '';
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<bool> loadRegistrationList({
|
||||
@@ -50,28 +46,43 @@ class EventInfoViewModel extends StateNotifier<EventInfoState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestStreamKey(EventRegistrationItem item) async {
|
||||
/// 获取局域网 IP
|
||||
|
||||
Future<String?> 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<String?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ class RecordingContext {
|
||||
required this.playerPhone,
|
||||
this.laneNo,
|
||||
this.status,
|
||||
this.isTeam = false,
|
||||
|
||||
/// 对方队长
|
||||
this.opponentName = '',
|
||||
this.teamLeaderName = '',
|
||||
});
|
||||
|
||||
const RecordingContext.empty()
|
||||
@@ -20,7 +25,10 @@ class RecordingContext {
|
||||
playerName = '',
|
||||
playerPhone = '',
|
||||
laneNo = null,
|
||||
status = null;
|
||||
status = null,
|
||||
isTeam = false,
|
||||
opponentName = '',
|
||||
teamLeaderName = '';
|
||||
|
||||
final String eventTitle;
|
||||
final String matchName;
|
||||
@@ -31,8 +39,12 @@ class RecordingContext {
|
||||
final String playerPhone;
|
||||
final String? laneNo;
|
||||
final String? status;
|
||||
final bool isTeam;
|
||||
final String opponentName;
|
||||
final String teamLeaderName;
|
||||
|
||||
String get title => '$matchName $group';
|
||||
String get title =>
|
||||
isTeam ? '$teamLeaderName 队伍 vs $opponentName 队伍' : '$playerName $group';
|
||||
|
||||
String get address => venue;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
@@ -8,9 +9,11 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
|
||||
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
||||
@@ -20,7 +23,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';
|
||||
|
||||
@@ -45,19 +47,27 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
var _immersiveApplied = false;
|
||||
var _previewReady = false;
|
||||
var _stoppingByUser = false;
|
||||
var _bootstrapScheduled = false;
|
||||
var _bootstrapStarted = false;
|
||||
var _releasingResources = false;
|
||||
var _controllerDisposed = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
DeviceHealthSnapshot? _deviceHealthSnapshot;
|
||||
Future<void>? _deviceHealthFuture;
|
||||
final Stopwatch _pageEntryStopwatch = Stopwatch();
|
||||
String? _mainCameraId;
|
||||
String? _ultraWideCameraId;
|
||||
double _ultraWideZoomRatio = 1.0;
|
||||
|
||||
@override
|
||||
/// 首帧后初始化录制流程
|
||||
/// 创建推流控制器,业务初始化等待路由动画结束。
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageEntryStopwatch.start();
|
||||
_streamController = ApiVideoLiveStreamController(
|
||||
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||
resolution: Resolution.RESOLUTION_1080,
|
||||
resolution: Resolution.RESOLUTION_720,
|
||||
fps: 30,
|
||||
),
|
||||
onConnectionSuccess: () => {debugPrint('推流成功')},
|
||||
@@ -88,20 +98,65 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
.setError(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (identical(animation, _routeAnimation)) return;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = animation;
|
||||
if (animation == null || animation.status == AnimationStatus.completed) {
|
||||
_scheduleBootstrap();
|
||||
} else {
|
||||
animation.addStatusListener(_handleRouteAnimationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRouteAnimationStatus(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed) return;
|
||||
logRecordingPerformance(
|
||||
'route transition completed',
|
||||
_pageEntryStopwatch.elapsed,
|
||||
);
|
||||
_scheduleBootstrap();
|
||||
}
|
||||
|
||||
void _scheduleBootstrap() {
|
||||
if (_bootstrapScheduled || _bootstrapStarted || _releasingResources) return;
|
||||
_bootstrapScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _releasingResources || _bootstrapStarted) return;
|
||||
_bootstrapStarted = true;
|
||||
_bootstrapScheduled = false;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = null;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setRecordingContext(widget.recordingContext);
|
||||
_bootstrap();
|
||||
unawaited(_bootstrap());
|
||||
});
|
||||
}
|
||||
|
||||
/// 检查设备健康状态并弹窗提示
|
||||
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
||||
final snapshot = await AppPlatformInfo.deviceHealth();
|
||||
if (!mounted) return;
|
||||
Future<void> _loadDeviceHealth() async {
|
||||
try {
|
||||
_deviceHealthSnapshot = await measureRecordingOperation(
|
||||
'device health',
|
||||
AppPlatformInfo.deviceHealth,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('读取设备健康状态失败: $error');
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用当前页面缓存的健康状态,在开始录制前按需提示。
|
||||
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
await _deviceHealthFuture;
|
||||
if (!mounted) return;
|
||||
final snapshot = _deviceHealthSnapshot;
|
||||
if (snapshot == null) return;
|
||||
final lines = DeviceHealthChecker.warningLines(snapshot);
|
||||
if (lines.isEmpty) return;
|
||||
|
||||
@@ -112,16 +167,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面启动:健康检查、进入录制模式、准备相机会话
|
||||
/// 路由动画完成后再准备权限和相机,避免阻塞转场。
|
||||
Future<void> _bootstrap() async {
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
await measureRecordingOperation(
|
||||
'enter immersive mode',
|
||||
_enterRecordingMode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final permissions = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.prepareRequiredPermissions();
|
||||
if (!mounted) return;
|
||||
if (!permissions.allGranted) return;
|
||||
await measureRecordingOperation(
|
||||
'preview initialize',
|
||||
_initializeLiveStreamPreview,
|
||||
);
|
||||
if (!mounted || !_previewReady) return;
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
unawaited(_loadPostPreviewTasks());
|
||||
}
|
||||
|
||||
await _enterRecordingMode();
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||
if (!mounted) return;
|
||||
await _initializeLiveStreamPreview();
|
||||
Future<void> _loadPostPreviewTasks() async {
|
||||
try {
|
||||
await Future.wait([
|
||||
measureRecordingOperation(
|
||||
'camera capabilities',
|
||||
_loadBackCameraCapabilities,
|
||||
),
|
||||
ref.read(recordingViewModelProvider.notifier).loadSystemAdvisories(),
|
||||
_deviceHealthFuture ??= _loadDeviceHealth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
debugPrint('加载录制页辅助状态失败: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeLiveStreamPreview() async {
|
||||
@@ -132,7 +211,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setPreviewReady(ready: true);
|
||||
await _loadBackCameraCapabilities();
|
||||
} on PlatformException catch (error) {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
@@ -154,6 +232,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
Future<void> _loadBackCameraCapabilities() async {
|
||||
try {
|
||||
final cameras = await _streamController.getBackCameras();
|
||||
if (!mounted || _releasingResources) return;
|
||||
if (cameras.isEmpty) return;
|
||||
final main = cameras.first;
|
||||
final widest = cameras.reduce(
|
||||
@@ -179,6 +258,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
maxZoomRatio: 1.0,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted || _releasingResources) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.updateZoomCapabilities(
|
||||
@@ -217,8 +297,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
final ready = ref.read(recordingViewModelProvider).session.isPreviewReady;
|
||||
if (ready) return true;
|
||||
if (!mounted) return false;
|
||||
AppToast.show('相机预览启动失败,请重试');
|
||||
return false;
|
||||
await measureRecordingOperation(
|
||||
'preview initialize after permission',
|
||||
_initializeLiveStreamPreview,
|
||||
);
|
||||
if (!mounted || !_previewReady) {
|
||||
AppToast.show('相机预览启动失败,请重试');
|
||||
return false;
|
||||
}
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
unawaited(_loadPostPreviewTasks());
|
||||
return true;
|
||||
}
|
||||
if (!mounted) return false;
|
||||
|
||||
@@ -314,7 +403,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
|
||||
/// 返回扫码页,准备新一轮录制。
|
||||
void _recordNewRound() {
|
||||
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
|
||||
AppNavigator.pop(context: context);
|
||||
}
|
||||
|
||||
/// 推流结束后按需弹出完成对话框
|
||||
@@ -337,45 +426,58 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出沉浸式并释放录制会话
|
||||
Future<void> _exitRecordingMode() async {
|
||||
if (!_immersiveApplied) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||
await _disposeStreamController();
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
_immersiveApplied = false;
|
||||
}
|
||||
|
||||
@override
|
||||
/// 页面销毁时恢复系统 UI
|
||||
/// 路由反向动画结束、页面销毁后统一释放资源。
|
||||
void dispose() {
|
||||
if (_immersiveApplied) {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
}
|
||||
_disposeStreamController();
|
||||
_releasingResources = true;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = null;
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
unawaited(_releaseResources(viewModel));
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _releaseResources(RecordingViewModel viewModel) async {
|
||||
await measureRecordingOperation('page resource release', () async {
|
||||
if (_immersiveApplied) {
|
||||
_immersiveApplied = false;
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
} catch (error) {
|
||||
debugPrint('恢复系统栏失败: $error');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await viewModel.teardown();
|
||||
} catch (error) {
|
||||
debugPrint('清理录制状态失败: $error');
|
||||
}
|
||||
await _disposeStreamController();
|
||||
});
|
||||
}
|
||||
|
||||
/// 只 dispose 一次;原生 dispose 统一停止推流和预览。
|
||||
Future<void> _disposeStreamController() async {
|
||||
if (_controllerDisposed) return;
|
||||
_controllerDisposed = true;
|
||||
await _streamController.stop();
|
||||
await _streamController.dispose();
|
||||
try {
|
||||
await measureRecordingOperation(
|
||||
'stream controller dispose',
|
||||
_streamController.dispose,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('释放推流控制器失败: $error\n$stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
/// 构建录制页 UI
|
||||
Widget build(BuildContext context) {
|
||||
return _RecordingPopScope(
|
||||
onExitRecordingMode: _exitRecordingMode,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Column(
|
||||
@@ -411,12 +513,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
}
|
||||
|
||||
class _RecordingPopScope extends ConsumerWidget {
|
||||
const _RecordingPopScope({
|
||||
required this.onExitRecordingMode,
|
||||
required this.child,
|
||||
});
|
||||
const _RecordingPopScope({required this.child});
|
||||
|
||||
final Future<void> Function() onExitRecordingMode;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -427,11 +525,8 @@ class _RecordingPopScope extends ConsumerWidget {
|
||||
|
||||
return PopScope(
|
||||
canPop: !isRecording,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
await onExitRecordingMode();
|
||||
return;
|
||||
}
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
if (isRecording) {
|
||||
AppToast.show('录制中无法返回,请先停止录制');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
Future<T> measureRecordingOperation<T>(
|
||||
String label,
|
||||
Future<T> Function() operation,
|
||||
) async {
|
||||
if (kReleaseMode) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
debugPrint(
|
||||
'[RecordingPerformance] $label: ${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void logRecordingPerformance(String label, Duration elapsed) {
|
||||
if (kReleaseMode) return;
|
||||
debugPrint('[RecordingPerformance] $label: ${elapsed.inMilliseconds}ms');
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
|
||||
|
||||
/// 录制页状态 Provider。
|
||||
final recordingViewModelProvider =
|
||||
@@ -32,6 +33,8 @@ class RecordingRequiredPermissions {
|
||||
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
Timer? _elapsedTimer;
|
||||
DateTime? _recordingStartedAt;
|
||||
RecordingRequiredPermissions? _cachedRequiredPermissions;
|
||||
var _sessionGeneration = 0;
|
||||
|
||||
/// 初始化状态并注册销毁回调。
|
||||
@override
|
||||
@@ -52,55 +55,88 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
state = state.copyWith(recordingContext: recordingContext);
|
||||
}
|
||||
|
||||
/// 申请权限并检查系统设置。
|
||||
Future<void> prepareSession() async {
|
||||
/// 准备相机和麦克风权限;已授权时复用当前会话结果。
|
||||
Future<RecordingRequiredPermissions> prepareRequiredPermissions({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final generation = _sessionGeneration;
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
||||
return;
|
||||
return const RecordingRequiredPermissions(
|
||||
cameraGranted: false,
|
||||
microphoneGranted: false,
|
||||
);
|
||||
}
|
||||
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制'));
|
||||
return;
|
||||
final cached = _cachedRequiredPermissions;
|
||||
if (!forceRefresh && cached?.allGranted == true) {
|
||||
return cached!;
|
||||
}
|
||||
|
||||
final microphoneGranted =
|
||||
permissions[Permission.microphone]?.isGranted ?? false;
|
||||
final permissions = await measureRecordingOperation(
|
||||
'required permissions',
|
||||
() => PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]),
|
||||
);
|
||||
final result = RecordingRequiredPermissions(
|
||||
cameraGranted: _isPermissionGranted(permissions[Permission.camera]),
|
||||
microphoneGranted: _isPermissionGranted(
|
||||
permissions[Permission.microphone],
|
||||
),
|
||||
);
|
||||
if (generation != _sessionGeneration) return result;
|
||||
_cachedRequiredPermissions = result;
|
||||
final errorMessage = !result.cameraGranted
|
||||
? '需要相机权限才能录制'
|
||||
: (!result.microphoneGranted ? '需要录音权限才能录制' : null);
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
isMicrophoneGranted: result.microphoneGranted,
|
||||
errorMessage: errorMessage,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 加载不影响相机首帧的通知、勿扰和电池状态。
|
||||
Future<void> loadSystemAdvisories() async {
|
||||
final generation = _sessionGeneration;
|
||||
final notificationFuture = Platform.isAndroid
|
||||
? PermissionService.requestMissing([Permission.notification])
|
||||
: Future.value(<Permission, PermissionStatus>{});
|
||||
final results = await measureRecordingOperation(
|
||||
'system advisories',
|
||||
() => Future.wait<dynamic>([
|
||||
notificationFuture,
|
||||
RecordingPlatform.hasNotificationPolicyAccess(),
|
||||
RecordingPlatform.isIgnoringBatteryOptimizations(),
|
||||
]),
|
||||
);
|
||||
final notificationPermissions =
|
||||
results[0] as Map<Permission, PermissionStatus>;
|
||||
final notificationsGranted = Platform.isAndroid
|
||||
? (permissions[Permission.notification]?.isGranted ?? false)
|
||||
? _isPermissionGranted(notificationPermissions[Permission.notification])
|
||||
: true;
|
||||
|
||||
final hasDnd = results[1] as bool;
|
||||
final batteryIgnored = results[2] as bool;
|
||||
if (generation != _sessionGeneration) return;
|
||||
final warnings = <String>[];
|
||||
if (Platform.isAndroid && !notificationsGranted) {
|
||||
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
|
||||
}
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
hasDndAccess: hasDnd,
|
||||
isBatteryOptimizedIgnored: batteryIgnored,
|
||||
isMicrophoneGranted: microphoneGranted,
|
||||
notificationsGranted: notificationsGranted,
|
||||
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
|
||||
errorMessage: null,
|
||||
clearPermissionWarning: warnings.isEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
}
|
||||
|
||||
void setPreviewReady({required bool ready, String? errorMessage}) {
|
||||
@@ -112,27 +148,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
||||
Future<RecordingRequiredPermissions>
|
||||
ensureCameraAndMicrophonePermissions() async {
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
final cameraGranted = _isPermissionGranted(permissions[Permission.camera]);
|
||||
final microphoneGranted = _isPermissionGranted(
|
||||
permissions[Permission.microphone],
|
||||
);
|
||||
|
||||
_updateSession((s) => s.copyWith(isMicrophoneGranted: microphoneGranted));
|
||||
|
||||
if (cameraGranted && !state.session.isPreviewReady) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
_updateSession((s) => s.copyWith(isPreviewReady: true));
|
||||
}
|
||||
|
||||
return RecordingRequiredPermissions(
|
||||
cameraGranted: cameraGranted,
|
||||
microphoneGranted: microphoneGranted,
|
||||
);
|
||||
final cached = _cachedRequiredPermissions;
|
||||
return prepareRequiredPermissions(forceRefresh: cached?.allGranted != true);
|
||||
}
|
||||
|
||||
bool _isPermissionGranted(PermissionStatus? status) {
|
||||
@@ -280,14 +297,15 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
_updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored));
|
||||
}
|
||||
|
||||
/// 退出录制页时释放相机、勿扰和状态订阅。
|
||||
/// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。
|
||||
Future<void> teardown() async {
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
_sessionGeneration++;
|
||||
_cachedRequiredPermissions = null;
|
||||
_recordingStartedAt = null;
|
||||
_elapsedTimer?.cancel();
|
||||
_elapsedTimer = null;
|
||||
state = state.copyWith(session: const RecordingSessionState());
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
}
|
||||
|
||||
/// Provider 销毁时取消状态流订阅。
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -82,21 +82,21 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (permissionWarning != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.r,
|
||||
vertical: 8.r,
|
||||
),
|
||||
child: Text(
|
||||
permissionWarning!,
|
||||
style: TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// if (permissionWarning != null)
|
||||
// Padding(
|
||||
// padding: EdgeInsets.symmetric(
|
||||
// horizontal: 16.r,
|
||||
// vertical: 8.r,
|
||||
// ),
|
||||
// child: Text(
|
||||
// permissionWarning!,
|
||||
// style: TextStyle(
|
||||
// color: Colors.orangeAccent,
|
||||
// fontSize: 12.sp,
|
||||
// ),
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// ),
|
||||
RecordingSetupHintsWidget(
|
||||
hasDndAccess: hasDndAccess,
|
||||
isBatteryIgnored: isBatteryOptimizedIgnored,
|
||||
|
||||
@@ -10,9 +10,9 @@ Future<void> showRecordingSavedDialog(
|
||||
}) {
|
||||
return RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已提交 NAS 录制\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
title: '本轮比赛视频已保存\n请选择后续录制信息',
|
||||
leftText: '继当前选手',
|
||||
rightText: '录新选手',
|
||||
onLeftPressed: onContinueRound,
|
||||
onRightPressed: onRecordNewRound,
|
||||
barrierDismissible: false,
|
||||
|
||||
@@ -7,7 +7,6 @@ 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/features/auth/view_model_auth/view_model_auth.dart';
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_list.dart';
|
||||
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||
import 'package:recording_tool/features/events/pages/page_event_info.dart';
|
||||
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
|
||||
@@ -127,18 +126,18 @@ class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
|
||||
onPressed: _handleViewRecords,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: _BottomOutlineButton(
|
||||
label: '参赛队伍',
|
||||
onPressed: () async {
|
||||
await AppNavigator.push(
|
||||
const CompetitionTeamListPage(),
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// SizedBox(width: 12.w),
|
||||
// Expanded(
|
||||
// child: _BottomOutlineButton(
|
||||
// label: '参赛队伍',
|
||||
// onPressed: () async {
|
||||
// await AppNavigator.push(
|
||||
// const CompetitionTeamListPage(),
|
||||
// context: context,
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -1,2 +1,2 @@
|
||||
#Mon Jul 13 17:53:35 CST 2026
|
||||
#Fri Jul 17 16:25:39 CST 2026
|
||||
gradle.version=8.2
|
||||
|
||||
+126
-15
@@ -2,6 +2,8 @@ package video.api.flutter.livestream
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import io.flutter.view.TextureRegistry
|
||||
@@ -17,7 +19,15 @@ import io.github.thibaultbee.streampack.utils.frontCameraList
|
||||
import io.github.thibaultbee.streampack.utils.isBackCamera
|
||||
import io.github.thibaultbee.streampack.utils.isExternalCamera
|
||||
import io.github.thibaultbee.streampack.utils.isFrontCamera
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FlutterLiveStreamView(
|
||||
private val context: Context,
|
||||
@@ -39,9 +49,21 @@ class FlutterLiveStreamView(
|
||||
initialOnConnectionListener = this,
|
||||
initialOnErrorListener = this
|
||||
)
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val operationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val operationMutex = Mutex()
|
||||
private val disposeLock = Any()
|
||||
private val disposeCallbacks =
|
||||
mutableListOf<Pair<() -> Unit, (Exception) -> Unit>>()
|
||||
|
||||
@Volatile
|
||||
private var _isPreviewing = false
|
||||
@Volatile
|
||||
private var _isStreaming = false
|
||||
@Volatile
|
||||
private var _isDisposing = false
|
||||
@Volatile
|
||||
private var _isDisposed = false
|
||||
val isStreaming: Boolean
|
||||
get() = _isStreaming
|
||||
|
||||
@@ -63,7 +85,7 @@ class FlutterLiveStreamView(
|
||||
|
||||
val wasPreviewing = _isPreviewing
|
||||
if (wasPreviewing) {
|
||||
stopPreview()
|
||||
stopPreviewInternal()
|
||||
}
|
||||
streamer.configure(videoConfig)
|
||||
_videoConfig = videoConfig
|
||||
@@ -174,13 +196,58 @@ class FlutterLiveStreamView(
|
||||
setCamera(cameraList.first(), onSuccess, onError)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
stopStream()
|
||||
streamer.stopPreview()
|
||||
flutterTexture.release()
|
||||
fun dispose(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
|
||||
var shouldStartDispose = false
|
||||
synchronized(disposeLock) {
|
||||
if (_isDisposed) {
|
||||
mainHandler.post(onSuccess)
|
||||
return
|
||||
}
|
||||
disposeCallbacks.add(onSuccess to onError)
|
||||
if (!_isDisposing) {
|
||||
_isDisposing = true
|
||||
shouldStartDispose = true
|
||||
}
|
||||
}
|
||||
if (!shouldStartDispose) return
|
||||
|
||||
operationScope.launch {
|
||||
var failure: Exception? = null
|
||||
operationMutex.withLock {
|
||||
try {
|
||||
stopStreamInternal()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e)
|
||||
failure = e
|
||||
}
|
||||
try {
|
||||
stopPreviewInternal()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e)
|
||||
failure = failure ?: e
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
try {
|
||||
flutterTexture.release()
|
||||
} catch (e: Exception) {
|
||||
failure = failure ?: e
|
||||
}
|
||||
val callbacks = synchronized(disposeLock) {
|
||||
_isDisposed = true
|
||||
_isDisposing = false
|
||||
disposeCallbacks.toList().also { disposeCallbacks.clear() }
|
||||
}
|
||||
callbacks.forEach { (success, error) ->
|
||||
failure?.let(error) ?: success()
|
||||
}
|
||||
}
|
||||
operationScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun startStream(url: String) {
|
||||
check(!_isDisposing && !_isDisposed) { "Live stream has been disposed" }
|
||||
runBlocking {
|
||||
streamer.connect(url)
|
||||
try {
|
||||
@@ -194,18 +261,33 @@ class FlutterLiveStreamView(
|
||||
}
|
||||
}
|
||||
|
||||
fun stopStream() {
|
||||
val isConnected = streamer.isConnected
|
||||
runBlocking {
|
||||
streamer.stopStream()
|
||||
streamer.disconnect()
|
||||
if (isConnected) {
|
||||
onDisconnected()
|
||||
}
|
||||
_isStreaming = false
|
||||
fun stopStream(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
|
||||
runBackgroundOperation(onSuccess, onError) {
|
||||
stopStreamInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stopStreamInternal() {
|
||||
if (!_isStreaming && !streamer.isConnected) return
|
||||
val isConnected = streamer.isConnected
|
||||
var failure: Exception? = null
|
||||
try {
|
||||
streamer.stopStream()
|
||||
} catch (e: Exception) {
|
||||
failure = e
|
||||
}
|
||||
try {
|
||||
streamer.disconnect()
|
||||
} catch (e: Exception) {
|
||||
failure = failure ?: e
|
||||
}
|
||||
if (isConnected) {
|
||||
onDisconnected()
|
||||
}
|
||||
_isStreaming = false
|
||||
failure?.let { throw it }
|
||||
}
|
||||
|
||||
fun startPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
|
||||
permissionsManager.requestPermission(
|
||||
Manifest.permission.CAMERA,
|
||||
@@ -239,11 +321,40 @@ class FlutterLiveStreamView(
|
||||
})
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
fun stopPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
|
||||
runBackgroundOperation(onSuccess, onError) {
|
||||
stopPreviewInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPreviewInternal() {
|
||||
if (!_isPreviewing) return
|
||||
streamer.stopPreview()
|
||||
_isPreviewing = false
|
||||
}
|
||||
|
||||
private fun runBackgroundOperation(
|
||||
onSuccess: () -> Unit,
|
||||
onError: (Exception) -> Unit,
|
||||
operation: suspend () -> Unit,
|
||||
) {
|
||||
if (_isDisposing || _isDisposed) {
|
||||
mainHandler.post(onSuccess)
|
||||
return
|
||||
}
|
||||
operationScope.launch {
|
||||
val failure = try {
|
||||
operationMutex.withLock { operation() }
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
failure?.let(onError) ?: onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSurface(resolution: Size): Surface {
|
||||
val surfaceTexture = flutterTexture.surfaceTexture().apply {
|
||||
setDefaultBufferSize(
|
||||
|
||||
+73
-20
@@ -50,27 +50,44 @@ class MethodCallHandlerImpl(
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"create" -> {
|
||||
try {
|
||||
flutterView?.dispose()
|
||||
flutterView = FlutterLiveStreamView(
|
||||
context,
|
||||
textureRegistry,
|
||||
permissionsManager,
|
||||
{ sendConnected() },
|
||||
{ sendDisconnected() },
|
||||
{ sendConnectionFailed(it) },
|
||||
{ sendError(it) },
|
||||
{ sendVideoSizeChanged(it) }
|
||||
val previousView = flutterView
|
||||
if (previousView == null) {
|
||||
createFlutterView(result)
|
||||
} else {
|
||||
previousView.dispose(
|
||||
onSuccess = {
|
||||
if (flutterView === previousView) {
|
||||
flutterView = null
|
||||
}
|
||||
createFlutterView(result)
|
||||
},
|
||||
onError = {
|
||||
result.error("failed_to_replace_live_stream", it.message, null)
|
||||
},
|
||||
)
|
||||
result.success(mapOf("textureId" to flutterView!!.textureId))
|
||||
} catch (e: Exception) {
|
||||
result.error("failed_to_create_live_stream", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
"dispose" -> {
|
||||
flutterView?.dispose()
|
||||
flutterView = null
|
||||
val view = flutterView
|
||||
if (view == null) {
|
||||
result.success(null)
|
||||
} else {
|
||||
view.dispose(
|
||||
onSuccess = {
|
||||
if (flutterView === view) {
|
||||
flutterView = null
|
||||
}
|
||||
result.success(null)
|
||||
},
|
||||
onError = {
|
||||
if (flutterView === view) {
|
||||
flutterView = null
|
||||
}
|
||||
result.error("failed_to_dispose_live_stream", it.message, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"setVideoConfig" -> {
|
||||
@@ -128,8 +145,17 @@ class MethodCallHandlerImpl(
|
||||
}
|
||||
|
||||
"stopPreview" -> {
|
||||
flutterView?.stopPreview()
|
||||
result.success(null)
|
||||
val view = flutterView
|
||||
if (view == null) {
|
||||
result.success(null)
|
||||
} else {
|
||||
view.stopPreview(
|
||||
onSuccess = { result.success(null) },
|
||||
onError = {
|
||||
result.error("failed_to_stop_preview", it.message, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"startStreaming" -> {
|
||||
@@ -163,8 +189,17 @@ class MethodCallHandlerImpl(
|
||||
}
|
||||
|
||||
"stopStreaming" -> {
|
||||
flutterView?.stopStream()
|
||||
result.success(null)
|
||||
val view = flutterView
|
||||
if (view == null) {
|
||||
result.success(null)
|
||||
} else {
|
||||
view.stopStream(
|
||||
onSuccess = { result.success(null) },
|
||||
onError = {
|
||||
result.error("failed_to_stop_stream", it.message, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"getIsStreaming" -> result.success(mapOf("isStreaming" to flutterView!!.isStreaming))
|
||||
@@ -275,6 +310,24 @@ class MethodCallHandlerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFlutterView(result: MethodChannel.Result) {
|
||||
try {
|
||||
flutterView = FlutterLiveStreamView(
|
||||
context,
|
||||
textureRegistry,
|
||||
permissionsManager,
|
||||
{ sendConnected() },
|
||||
{ sendDisconnected() },
|
||||
{ sendConnectionFailed(it) },
|
||||
{ sendError(it) },
|
||||
{ sendVideoSizeChanged(it) },
|
||||
)
|
||||
result.success(mapOf("textureId" to flutterView!!.textureId))
|
||||
} catch (e: Exception) {
|
||||
result.error("failed_to_create_live_stream", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendConnected() {
|
||||
sendEvent("connected")
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ class _ApiVideoCameraPreviewState extends State<ApiVideoCameraPreview> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.stopPreview();
|
||||
widget.controller.removeWidgetListener(_widgetListener);
|
||||
widget.controller.removeEventsListener(_eventsListener);
|
||||
super.dispose();
|
||||
|
||||
@@ -26,6 +26,8 @@ class ApiVideoLiveStreamController {
|
||||
int get textureId => _textureId;
|
||||
|
||||
bool _isInitialized = false;
|
||||
Future<void>? _initializeFuture;
|
||||
Future<void>? _disposeFuture;
|
||||
|
||||
/// Gets the current state of the video player.
|
||||
bool get isInitialized => _isInitialized;
|
||||
@@ -68,7 +70,14 @@ class ApiVideoLiveStreamController {
|
||||
}
|
||||
|
||||
/// Creates a new live stream instance with initial audio and video configurations.
|
||||
Future<void> initialize() async {
|
||||
Future<void> initialize() {
|
||||
if (_disposeFuture != null) {
|
||||
throw StateError('Cannot initialize a disposed controller');
|
||||
}
|
||||
return _initializeFuture ??= _initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_textureId = await _platform.initialize() ?? kUninitializedTextureId;
|
||||
|
||||
_eventSubscription = _platform
|
||||
@@ -91,12 +100,23 @@ class ApiVideoLiveStreamController {
|
||||
}
|
||||
|
||||
/// Disposes the live stream instance.
|
||||
Future<void> dispose() async {
|
||||
Future<void> dispose() {
|
||||
return _disposeFuture ??= _dispose();
|
||||
}
|
||||
|
||||
Future<void> _dispose() async {
|
||||
try {
|
||||
await _initializeFuture;
|
||||
} catch (_) {
|
||||
// A partially initialized native view still needs to be disposed.
|
||||
}
|
||||
await _eventSubscription?.cancel();
|
||||
_eventSubscription = null;
|
||||
_eventsListeners.clear();
|
||||
_widgetListeners.clear();
|
||||
await _platform.dispose();
|
||||
return;
|
||||
_isInitialized = false;
|
||||
_textureId = kUninitializedTextureId;
|
||||
}
|
||||
|
||||
/// Sets new video parameters.
|
||||
|
||||
Reference in New Issue
Block a user