Author SHA1 Message Date
linfeng 0c1fb24685 update 2026-07-29 18:47:45 +08:00
linfeng a849457172 点击录新选手返回扫码页 2026-07-29 18:22:37 +08:00
linfeng d6a7e74809 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 18:12:12 +08:00
linfeng df38ecee18 登录后清空输入框 2026-07-29 18:06:53 +08:00
linfeng 9dea31f76e Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 18:01:06 +08:00
linfeng 7faa8e5c47 更新获取赛事列表功能,增加 NAS IP 地址参数以支持动态请求 2026-07-29 18:00:45 +08:00
linfeng 17799af2e7 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 11:51:21 +08:00
linfeng 7d65be0514 选手赛项列表增加 选手与该赛项的选手编号属性 2026-07-29 11:51:11 +08:00
linfeng d4920e2b98 1.录像页面,根据个人赛和团队赛 动态 录像页标题
2.录像结束弹窗按钮文本修改
2026-07-29 11:18:58 +08:00
linfeng a19d9dfa6d Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 10:15:25 +08:00
linfeng 40799f2d15 扫码页支持返回上一页 2026-07-29 10:13:47 +08:00
linfeng 934b3ea2fa Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 09:19:19 +08:00
linfeng f7f7413b55 优化扫码页 UI 2026-07-28 18:50:14 +08:00
linfeng 8e599f5a86 完成视频播放UI 调整 2026-07-28 18:02:40 +08:00
linfeng 36253aacbe 优化录制页性能 2026-07-28 13:47:25 +08:00
linfeng a56a42c7c8 1.修改文案
2.副裁 APP 不需要查看参赛队伍
2026-07-28 12:00:16 +08:00
linfeng d29b4ed5bf 1.不展示黄字信息
2.新增忽略文件
2026-07-28 11:35:36 +08:00
linfeng b9c0d60f2a 调整 副裁 设计稿比例 2026-07-28 10:57:06 +08:00
linfeng 43b967d2f8 降级 network_info_plus 依赖版本至 7.0.0 2026-07-28 10:54:36 +08:00
linfeng 58de72ac92 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-28 10:54:27 +08:00
linfeng bfc76f469f 更新事件信息页面中的流媒体 URL 逻辑,恢复使用 streamKey 变量以增强灵活性,同时注释掉了之前的硬编码 URL。 2026-07-17 18:24:22 +08:00
linfeng 744e768d3a 在 UtilSearchNasIp 中添加探测开始时间记录和总耗时打印,增强了命中记录的调试信息;更新相关方法以传递开始时间参数。 2026-07-17 17:48:16 +08:00
linfeng 25a07cbd3b 添加 network_info_plus 依赖以支持局域网 IP 获取;在事件信息和录制页面中增强局域网 IP 查询功能,更新相关模型以包含 scheduleId 字段;重构录制页面以改善用户体验,修复推流控制器释放逻辑。 2026-07-17 17:31:04 +08:00
35 changed files with 866 additions and 331 deletions
+1 -1
View File
@@ -51,4 +51,4 @@ app.*.map.json
CLAUDE.md
AGENTS.md
test/
script
script/
+4 -1
View File
@@ -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.

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -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 "新增可视化请求插件"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "副裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
+3
View File
@@ -17,6 +17,9 @@ enum AuthApi {
/// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list');
+1 -1
View File
@@ -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 链接
+17 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
final payload = raw is Map ? Map<String, dynamic>.from(raw) : null;
if (payload == null ||
!(payload.containsKey('code') || payload.containsKey('message'))) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
// 先按 {code,message,data} 解包,再用业务 parser 解析 data
// 避免把 Map 直接强转成业务模型。
final wrapped = ApiResponse<dynamic>.fromJson(payload);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
@@ -52,7 +56,17 @@ class ApiClient {
);
}
return wrapped.data as T;
if (wrapped.data == null) {
if (null is T) return null as T;
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: '响应 data 为空',
details: raw,
);
}
return _parseData<T>(wrapped.data, parser);
} on DioException catch (error) {
throw _mapDioException(error);
}
+17 -1
View File
@@ -11,10 +11,26 @@ class ApiResponse<T> {
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
final rawData = json['data'];
T? data;
if (rawData != null && fromJsonT != null) {
data = fromJsonT(rawData);
} else if (rawData != null && fromJsonT == null) {
// 无 parser 时仅在类型已匹配时接收,避免 Map 被强转为业务模型。
if (rawData is T) {
data = rawData;
} else {
throw FormatException(
'ApiResponse data 类型不匹配,且未提供 fromJsonT;'
'期望 $T,实际 ${rawData.runtimeType}',
);
}
}
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
data: data,
);
}
}
+4
View File
@@ -18,6 +18,7 @@ class JwtDecodedData {
String? oId;
List<String>? oIds;
String? organizerId;
String? nasIpAddr;
JwtDecodedData({
this.authType,
@@ -28,6 +29,7 @@ class JwtDecodedData {
this.oId,
this.oIds,
this.organizerId,
this.nasIpAddr,
});
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
@@ -41,6 +43,7 @@ class JwtDecodedData {
? []
: List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
);
Map<String, dynamic> toJson() => {
@@ -52,6 +55,7 @@ class JwtDecodedData {
'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
};
}
+47 -21
View File
@@ -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,10 +8,10 @@ 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';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -23,14 +25,25 @@ class AuthPageWidget extends ConsumerStatefulWidget {
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: '905758');
_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
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage());
}
});
@@ -42,6 +55,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
super.dispose();
}
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
@@ -55,14 +74,11 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
backgroundColor: Colors.white,
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
Positioned.fill(
child: Image.asset(
_AuthAssets.pageBg,
width: double.infinity,
fit: BoxFit.fitWidth,
fit: BoxFit.fill,
),
),
SafeArea(
@@ -74,11 +90,20 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
SizedBox(height: 190.h),
ClipRRect(
borderRadius: BorderRadius.circular(24.r),
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
child: GestureDetector(
onTap: () {
_clickCount++;
if (_clickCount >= 5) {
_showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 18.h),
@@ -95,15 +120,15 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
isLoading: authState.isLoading,
onPressed: _handleSubmit,
),
SizedBox(height: 20.h),
AppButton(
onPressed: () async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
},
label: '获取设备码',
),
// SizedBox(height: 20.h),
// AppButton(
// onPressed: () async {
// final deviceCode = await DeviceUtils.deviceCode();
// if (!mounted) return;
// AppDialog.confirm(context, title: '设备码:$deviceCode');
// },
// label: '获取设备码',
// ),
],
),
),
@@ -121,6 +146,7 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
.auth(_controller.text);
if (!mounted) return;
if (success) {
_controller.clear();
AppNavigator.push(const ScanQrCodePage());
return;
}
+30 -4
View File
@@ -4,6 +4,7 @@ import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer {
/// [passCode] 口令码
@@ -29,15 +30,20 @@ class AuthServer {
/// 获取赛事列表
/// [path] 赛事目录
/// [nasIpAddr] NAS IP,由调用方传入,避免在 authProvider 内部再 read(authProvider)。
static Future<GetRecordListResModel?> getRecordList(
Ref ref,
String path,
) async {
Ref ref, {
required String path,
required String nasIpAddr,
}) async {
try {
if (nasIpAddr.isEmpty) {
throw const FormatException('无法获取NAS IP地址');
}
final apiClient = ref.read(apiClientProvider);
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
final data = await apiClient.get<GetRecordListResModel>(
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
'http://$nasIpAddr:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
wrapResponse: false,
parser: (json) {
@@ -55,4 +61,24 @@ class AuthServer {
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
}
}
@@ -1,12 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
import 'package:recording_tool/features/auth/server/server_auth.dart';
import 'package:recording_tool/features/auth/state/state_auth.dart';
@@ -33,7 +30,11 @@ class AuthViewModel extends StateNotifier<AuthState> {
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
state = const AuthState();
if (data.deviceAccessToken.isNotEmpty) {
parseTokenSetState(data.deviceAccessToken);
final ok = await parseTokenSetState();
if (!ok) {
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
return false;
}
}
return true;
} on FormatException catch (error) {
@@ -49,24 +50,34 @@ class AuthViewModel extends StateNotifier<AuthState> {
}
/// 解析 TOKEN,并更新状态
Future<bool> parseTokenSetState(String token) async {
final decoded = JwtDecoder.decode(token);
final rawData = decoded['data'];
if (rawData is! Map) return false;
try {
final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
state = state.copyWith(jwtDecodedData: data);
return true;
} catch (error) {
debugPrint('认证失败,请重试: $error');
return false;
}
Future<bool> parseTokenSetState() async {
final data = await AuthServer.getLoginDeviceInfo(_ref);
if (data == null) return false;
state = state.copyWith(jwtDecodedData: data);
return true;
// final decoded = JwtDecoder.decode(token);
// final rawData = decoded['data'];
// if (rawData is! Map) return false;
// try {
// final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
// state = state.copyWith(jwtDecodedData: data);
// return true;
// } catch (error) {
// debugPrint('认证失败,请重试: $error');
// return false;
// }
}
/// 获取赛事列表
Future<bool> getRecordList(String eventName) async {
if (eventName.isEmpty) return false;
final data = await AuthServer.getRecordList(_ref, eventName);
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return false;
final data = await AuthServer.getRecordList(
_ref,
path: eventName,
nasIpAddr: nasIpAddr,
);
if (data == null) return false;
if (data.items == null) return false;
state = state.copyWith(recordList: data.items);
@@ -76,7 +87,13 @@ class AuthViewModel extends StateNotifier<AuthState> {
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
Future<List<RecordListItem>?> fetchRecordList(String path) async {
if (path.isEmpty) return null;
final data = await AuthServer.getRecordList(_ref, path);
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return null;
final data = await AuthServer.getRecordList(
_ref,
path: path,
nasIpAddr: nasIpAddr,
);
return data?.items;
}
@@ -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,
};
}
}
@@ -127,6 +134,7 @@ class EventRegistrationItem {
required this.itemName,
required this.groupName,
required this.matchPlace,
this.matchStartTime = '',
this.matchEndTime = '',
this.completed = false,
@@ -136,6 +144,7 @@ class EventRegistrationItem {
this.userId = '',
this.playerName = '',
this.playerPhone = '',
this.playerNo = '',
});
final String eventId;
@@ -151,6 +160,7 @@ class EventRegistrationItem {
final String opponentId;
final String opponentName;
final List<EventTeamMember> teamMembers;
final String playerNo;
/// 来自报名列表父级,不在 item JSON 内
final String userId;
@@ -198,6 +208,7 @@ class EventRegistrationItem {
: const [],
userId: userId,
playerName: playerName,
playerNo: _readString(map, const ['playerNo']),
);
}
+58 -52
View File
@@ -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) {
@@ -521,9 +526,10 @@ String _formatOpponentLine(EventRegistrationItem item) {
}
String _formatNumberBadge(EventRegistrationItem item) {
final place = item.matchPlace.trim();
if (place.isEmpty) return '';
final match = RegExp(r'\d+').firstMatch(place);
if (match != null) return '${match.group(0)}';
return place;
return item.playerNo.trim().isEmpty ? '' : '${item.playerNo.trim()}';
// final place = item.matchPlace.trim();
// if (place.isEmpty) return '';
// final match = RegExp(r'\d+').firstMatch(place);
// if (match != null) return '${match.group(0)}号';
// return place;
}
@@ -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;
+148 -52
View File
@@ -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';
@@ -45,19 +48,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 +99,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 +168,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 +212,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 +233,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 +259,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
maxZoomRatio: 1.0,
);
} catch (_) {
if (!mounted || _releasingResources) return;
ref
.read(recordingViewModelProvider.notifier)
.updateZoomCapabilities(
@@ -217,8 +298,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;
@@ -337,45 +427,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 +514,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 +526,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,
@@ -10,6 +10,7 @@ import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_video_player.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -50,26 +51,26 @@ class RecordListPage extends ConsumerWidget {
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(title: '查看录像'),
appBar: AppPageBar(
centerTitle: false,
titleSpacing: 0,
titleWidget: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 4.h),
child: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 4.h),
Expanded(
child: visibleItems.isEmpty
? const AppEmptyView(message: '暂无录像内容')
@@ -196,7 +197,7 @@ class _DirectoryCard extends StatelessWidget {
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 72.h),
padding: EdgeInsets.fromLTRB(12.w, 12.h, 14.w, 12.h),
padding: EdgeInsets.fromLTRB(20.w, 12.h, 14.w, 12.h),
child: Row(
children: [
Expanded(
@@ -230,10 +231,22 @@ class _DirectoryCard extends StatelessWidget {
),
),
SizedBox(width: 12.w),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
Row(
children: [
// Text(
// '视频',
// style: TextStyle(
// fontSize: 14.sp,
// color: const Color(0xFF358BFE),
// fontWeight: FontWeight.w600,
// ),
// ),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
],
),
@@ -368,10 +381,9 @@ class _VideoThumbnailState extends State<_VideoThumbnail> {
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: Icon(
Icons.play_arrow_rounded,
size: 22.r,
color: Colors.white,
child: Image(
image: AssetImage(Assets.images.imageVideoIcon.path),
fit: BoxFit.cover,
),
),
),
@@ -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,
// );
// },
// ),
// ),
],
),
],
@@ -218,18 +217,50 @@ class _BrandHeader extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
Expanded(
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(17.r),
child: Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
),
),
// SizedBox(width: .w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
),
],
),
),
SizedBox(width: 8.w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
GestureDetector(
onTap: () {
AppNavigator.pop();
},
child: Row(
children: [
Icon(
Icons.chevron_left_rounded,
color: Colors.black,
size: 28.sp,
),
Text(
'返回',
style: TextStyle(
// color: const Color(0xFF53A7F3),
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
);
+5
View File
@@ -80,6 +80,10 @@ class $AssetsImagesGen {
AssetGenImage get imageTeamVsBg =>
const AssetGenImage('assets/images/image_team_vs_bg.png');
/// File path: assets/images/image_video_icon.png
AssetGenImage get imageVideoIcon =>
const AssetGenImage('assets/images/image_video_icon.png');
/// File path: assets/images/image_vs.png
AssetGenImage get imageVs =>
const AssetGenImage('assets/images/image_vs.png');
@@ -100,6 +104,7 @@ class $AssetsImagesGen {
imageScanQrcode,
imageStart,
imageTeamVsBg,
imageVideoIcon,
imageVs,
];
}
+8 -1
View File
@@ -14,6 +14,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
this.backgroundImage,
this.onBack,
this.actions,
this.centerTitle = true,
this.titleSpacing,
});
final String title;
@@ -22,6 +24,10 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback? onBack;
final List<Widget>? actions;
/// 标题是否居中;false 时左对齐,紧随返回按钮。
final bool centerTitle;
final double? titleSpacing;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@@ -33,7 +39,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
centerTitle: centerTitle,
titleSpacing: titleSpacing,
systemOverlayStyle: SystemUiOverlayStyle.light,
flexibleSpace: SizedBox.expand(
child: Image.asset(
@@ -1,2 +1,2 @@
#Mon Jul 13 17:53:35 CST 2026
#Fri Jul 17 16:25:39 CST 2026
gradle.version=8.2
@@ -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(
@@ -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.