Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707
This commit is contained in:
@@ -22,15 +22,20 @@ class GetRecordListResModel {
|
||||
|
||||
GetRecordListResModel({this.path, this.items});
|
||||
|
||||
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) =>
|
||||
GetRecordListResModel(
|
||||
path: json['path'],
|
||||
items: json['items'] == null
|
||||
? []
|
||||
: List<RecordListItem>.from(
|
||||
json['items']!.map((x) => RecordListItem.fromJson(x)),
|
||||
),
|
||||
);
|
||||
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) {
|
||||
final rawItems = json['items'];
|
||||
return GetRecordListResModel(
|
||||
path: json['path']?.toString(),
|
||||
items: rawItems is! List
|
||||
? []
|
||||
: rawItems
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(x) => RecordListItem.fromJson(Map<String, dynamic>.from(x)),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
@@ -40,6 +45,14 @@ class GetRecordListResModel {
|
||||
};
|
||||
}
|
||||
|
||||
enum RecordItemType {
|
||||
file('file'),
|
||||
directory('dir');
|
||||
|
||||
final String value;
|
||||
const RecordItemType(this.value);
|
||||
}
|
||||
|
||||
class RecordListItem {
|
||||
String? name;
|
||||
String? path;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
|
||||
@@ -26,10 +25,12 @@ class AuthPageWidget extends ConsumerStatefulWidget {
|
||||
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
/// 记录点击次数
|
||||
int _clickCount = 0;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: '999779');
|
||||
_controller = TextEditingController(text: '');
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// 静默探测 NAS,不阻塞登录 / 自动跳转
|
||||
@@ -37,6 +38,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
@@ -48,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);
|
||||
@@ -61,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(
|
||||
@@ -80,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),
|
||||
@@ -101,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: '获取设备码',
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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] 口令码
|
||||
@@ -28,17 +30,50 @@ class AuthServer {
|
||||
|
||||
/// 获取赛事列表
|
||||
/// [path] 赛事目录
|
||||
static Future<GetRecordListResModel> getRecordList(
|
||||
static Future<GetRecordListResModel?> getRecordList(
|
||||
Ref ref,
|
||||
String path,
|
||||
) async {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final data = await apiClient.get<GetRecordListResModel>(
|
||||
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
|
||||
queryParameters: {'path': path},
|
||||
parser: (json) =>
|
||||
GetRecordListResModel.fromJson(json as Map<String, dynamic>),
|
||||
);
|
||||
return data;
|
||||
try {
|
||||
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}',
|
||||
queryParameters: {'path': path},
|
||||
wrapResponse: false,
|
||||
parser: (json) {
|
||||
if (json is! Map) {
|
||||
throw const FormatException('录像列表响应格式错误');
|
||||
}
|
||||
return GetRecordListResModel.fromJson(
|
||||
Map<String, dynamic>.from(json),
|
||||
);
|
||||
},
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
debugPrint('getRecordList failed: $error');
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class AuthState {
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
|
||||
recordList: recordList ?? this.recordList,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +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_jwt.dart';
|
||||
import 'package:recording_tool/features/auth/model/model_auth.dart';
|
||||
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
||||
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
||||
|
||||
@@ -32,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) {
|
||||
@@ -48,29 +50,41 @@ 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);
|
||||
if (data.items == null || data.items!.isEmpty) return false;
|
||||
if (data == null) return false;
|
||||
if (data.items == null) return false;
|
||||
state = state.copyWith(recordList: data.items);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
|
||||
Future<List<RecordListItem>?> fetchRecordList(String path) async {
|
||||
if (path.isEmpty) return null;
|
||||
final data = await AuthServer.getRecordList(_ref, path);
|
||||
return data?.items;
|
||||
}
|
||||
|
||||
/// 清空授权信息(本地 token + 内存状态)
|
||||
Future<void> clearAuth() async {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:recording_tool/features/competition_teams/model/model_competitio
|
||||
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
|
||||
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
|
||||
import 'package:recording_tool/features/events/server/server_events.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';
|
||||
|
||||
@@ -79,14 +81,14 @@ class _CompetitionTeamDetailPageState
|
||||
final matchups = _matchups;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: Text(_title)),
|
||||
backgroundColor: const Color(0xFFF2F4F7),
|
||||
appBar: AppPageBar(title: _title),
|
||||
body: matchups.isEmpty
|
||||
? const AppEmptyView(message: '暂无对阵信息')
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 36.h),
|
||||
padding: EdgeInsets.only(bottom: 36.h),
|
||||
itemCount: matchups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 22.h),
|
||||
separatorBuilder: (_, _) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
final raw = matchups[index];
|
||||
final matchup = _toCompetitionMatchup(raw, index);
|
||||
@@ -95,8 +97,6 @@ class _CompetitionTeamDetailPageState
|
||||
}
|
||||
return _MatchupCard(
|
||||
matchup: matchup,
|
||||
index: index,
|
||||
matchTitle: raw.matchTitle?.trim() ?? '',
|
||||
onManualProcess: () => _handleManualProcess(raw, index),
|
||||
);
|
||||
},
|
||||
@@ -175,91 +175,74 @@ class _CompetitionTeamDetailPageState
|
||||
}
|
||||
|
||||
class _MatchupCard extends StatelessWidget {
|
||||
const _MatchupCard({
|
||||
required this.matchup,
|
||||
required this.index,
|
||||
required this.matchTitle,
|
||||
required this.onManualProcess,
|
||||
});
|
||||
const _MatchupCard({required this.matchup, required this.onManualProcess});
|
||||
|
||||
/// 背景图 image_team_vs_bg.png 的原始宽高比(1920 x 318)。
|
||||
static const double _vsBgAspectRatio = 1920 / 318;
|
||||
|
||||
final CompetitionMatchup matchup;
|
||||
final int index;
|
||||
final String matchTitle;
|
||||
final VoidCallback onManualProcess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = matchTitle.isEmpty ? '第 ${index + 1} 场' : matchTitle;
|
||||
return Container(
|
||||
key: ValueKey('competition-matchup-${matchup.id}'),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 18.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: const Color(0xFFC7CCD4)),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
color: Colors.white,
|
||||
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF7A828E),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
key: ValueKey('manual-process-${matchup.id}'),
|
||||
onPressed: onManualProcess,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'人工处理',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF078AF2),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: ValueKey('manual-process-${matchup.id}'),
|
||||
onPressed: onManualProcess,
|
||||
child: Text(
|
||||
'人工处理',
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF078AF2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamA,
|
||||
alignment: CrossAxisAlignment.start,
|
||||
winner: matchup.winnerTeamId == matchup.teamA.id,
|
||||
accentColor: const Color(0xFFFF6B75),
|
||||
AspectRatio(
|
||||
aspectRatio: _vsBgAspectRatio,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(Assets.images.imageTeamVsBg.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF303640),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamA,
|
||||
isLeft: true,
|
||||
winner: matchup.winnerTeamId == matchup.teamA.id,
|
||||
accentColor: const Color(0xFFFF6B75),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamB,
|
||||
isLeft: false,
|
||||
winner: matchup.winnerTeamId == matchup.teamB.id,
|
||||
accentColor: const Color(0xFF12A6C8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamB,
|
||||
alignment: CrossAxisAlignment.end,
|
||||
textAlign: TextAlign.end,
|
||||
winner: matchup.winnerTeamId == matchup.teamB.id,
|
||||
accentColor: const Color(0xFF20BFA9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -270,83 +253,72 @@ class _MatchupCard extends StatelessWidget {
|
||||
class _TeamPanel extends StatelessWidget {
|
||||
const _TeamPanel({
|
||||
required this.team,
|
||||
required this.alignment,
|
||||
required this.isLeft,
|
||||
required this.winner,
|
||||
required this.accentColor,
|
||||
this.textAlign = TextAlign.start,
|
||||
});
|
||||
|
||||
final CompetitionTeam team;
|
||||
final CrossAxisAlignment alignment;
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// 左半区(红色面板,右对齐、略偏上);右半区(蓝色面板,左对齐、略偏下)。
|
||||
final bool isLeft;
|
||||
final bool winner;
|
||||
final Color accentColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: alignment,
|
||||
final textAlign = isLeft ? TextAlign.end : TextAlign.start;
|
||||
|
||||
final nameRow = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
decoration: BoxDecoration(
|
||||
color: winner
|
||||
? accentColor.withValues(alpha: 0.14)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
team.name,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF282E37),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (winner) ...[
|
||||
SizedBox(width: 5.w),
|
||||
Icon(Icons.emoji_events, size: 17.r, color: accentColor),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
team.playerNames,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.45,
|
||||
color: const Color(0xFF4E5662),
|
||||
Flexible(
|
||||
child: Text(
|
||||
team.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF282E37),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (winner) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
SizedBox(width: 4.w),
|
||||
Icon(
|
||||
Icons.emoji_events,
|
||||
key: ValueKey('winner-${team.id}'),
|
||||
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Text(
|
||||
'胜方',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
size: 14.r,
|
||||
color: accentColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
// 中线两侧留出 VS 图案空间,外侧避开斜切边缘。
|
||||
padding: isLeft
|
||||
? EdgeInsets.only(left: 16.w, right: 30.w, bottom: 10.h)
|
||||
: EdgeInsets.only(left: 30.w, right: 16.w, top: 10.h),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: isLeft
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
nameRow,
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
team.playerNames,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF4E5662)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:recording_tool/features/competition_teams/model/model_competitio
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.dart';
|
||||
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
|
||||
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.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_error_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
|
||||
@@ -34,8 +35,8 @@ class _CompetitionTeamListPageState
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(competitionTeamsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('参赛队伍')),
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppPageBar(title: '参赛队伍'),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Builder(
|
||||
@@ -56,8 +57,8 @@ class _CompetitionTeamListPageState
|
||||
onRefresh: ref.read(competitionTeamsProvider.notifier).refresh,
|
||||
onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore,
|
||||
enablePullUp: state.hasMore,
|
||||
padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 28.h),
|
||||
separator: SizedBox(height: 14.h),
|
||||
padding: EdgeInsets.fromLTRB(10.w, 10.h, 10.w, 24.h),
|
||||
separator: SizedBox(height: 8.h),
|
||||
empty: const AppEmptyView(message: '暂无参赛队伍'),
|
||||
itemBuilder: (context, item, index) {
|
||||
return _CompetitionScheduleCard(
|
||||
@@ -97,33 +98,26 @@ class _CompetitionScheduleCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheduleText = item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
|
||||
|
||||
return Material(
|
||||
key: ValueKey('competition-team-item-${item.itemId}'),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 142.h),
|
||||
padding: EdgeInsets.all(16.r),
|
||||
constraints: BoxConstraints(minHeight: 86.h),
|
||||
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
border: Border.all(color: const Color(0xFFD7DBE2)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0F1A2230),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
@@ -131,23 +125,21 @@ class _CompetitionScheduleCard extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF20242B),
|
||||
fontSize: 13.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF30343A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
_InfoLine(
|
||||
icon: Icons.schedule_outlined,
|
||||
text: scheduleText,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_MetaText(_formatScheduleTime(item)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
SizedBox(width: 12.w),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 28.r,
|
||||
size: 20.r,
|
||||
color: const Color(0xFF9AA3AF),
|
||||
),
|
||||
],
|
||||
@@ -158,27 +150,40 @@ class _CompetitionScheduleCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoLine extends StatelessWidget {
|
||||
const _InfoLine({required this.icon, required this.text});
|
||||
class _MetaText extends StatelessWidget {
|
||||
const _MetaText(this.text);
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18.r, color: const Color(0xFF7B8491)),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF525B68)),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF6E747D),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatScheduleTime(CompetitionTeamListItem item) {
|
||||
final start = DateTime.tryParse(item.matchStartTime);
|
||||
final end = DateTime.tryParse(item.matchEndTime);
|
||||
if (start == null && end == null) {
|
||||
return item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
|
||||
}
|
||||
if (start != null && end != null) {
|
||||
return '${start.month}月${start.day}日 ${_formatClock(start)}-${_formatClock(end)}';
|
||||
}
|
||||
final value = start ?? end!;
|
||||
return '${value.month}月${value.day}日 ${_formatClock(value)}';
|
||||
}
|
||||
|
||||
String _formatClock(DateTime value) {
|
||||
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class PushSteamTestWidget extends StatefulWidget {
|
||||
const PushSteamTestWidget({
|
||||
super.key,
|
||||
this.rtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
|
||||
});
|
||||
|
||||
final String rtmpUrl;
|
||||
|
||||
@override
|
||||
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
|
||||
}
|
||||
|
||||
class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
|
||||
with WidgetsBindingObserver {
|
||||
late final ApiVideoLiveStreamController _controller;
|
||||
bool _ready = false;
|
||||
bool _isStreaming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_controller = ApiVideoLiveStreamController(
|
||||
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||
resolution: Resolution.RESOLUTION_1080,
|
||||
fps: 30,
|
||||
),
|
||||
onConnectionSuccess: () => {
|
||||
debugPrint('推流成功'),
|
||||
|
||||
setState(() => _isStreaming = true),
|
||||
},
|
||||
onConnectionFailed: (reason) {
|
||||
setState(() => _isStreaming = false);
|
||||
debugPrint('推流失败: $reason');
|
||||
},
|
||||
onDisconnection: () => {
|
||||
debugPrint('推流断开'),
|
||||
|
||||
setState(() => _isStreaming = false),
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
await _controller.initialize();
|
||||
await _controller.startPreview();
|
||||
|
||||
setState(() => _ready = true);
|
||||
} catch (e) {
|
||||
debugPrint('初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startPush() async {
|
||||
try {
|
||||
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
|
||||
await _controller.startStreaming(
|
||||
streamKey: target.streamKey,
|
||||
url: target.url,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
debugPrint('推流地址错误: ${e.message}');
|
||||
AppToast.show(e.message);
|
||||
} on PlatformException catch (e) {
|
||||
final message = e.message ?? e.code;
|
||||
debugPrint('推流失败: ${e.code} $message');
|
||||
AppToast.show('推流失败: $message');
|
||||
} catch (e) {
|
||||
debugPrint('推流失败: $e');
|
||||
AppToast.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopPush() => _controller.stopStreaming();
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.inactive) {
|
||||
_controller.stop();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_controller.startPreview();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: myAppBar(context: context),
|
||||
body: Stack(
|
||||
children: [
|
||||
if (_ready)
|
||||
ApiVideoCameraPreview(controller: _controller, fit: BoxFit.cover),
|
||||
Positioned(
|
||||
bottom: 32,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: FloatingActionButton(
|
||||
backgroundColor: _isStreaming ? Colors.red : Colors.green,
|
||||
onPressed: _ready
|
||||
? (_isStreaming ? _stopPush : _startPush)
|
||||
: null,
|
||||
child: Icon(_isStreaming ? Icons.stop : Icons.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get_thumbnail_video/index.dart';
|
||||
import 'package:get_thumbnail_video/video_thumbnail.dart';
|
||||
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';
|
||||
|
||||
/// NAS 文件服务地址,与 AuthServer.getRecordList 保持一致。
|
||||
const _nasBaseUrl = 'http://sheling.local:9001';
|
||||
|
||||
const _videoExtensions = {
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'flv',
|
||||
'ts',
|
||||
'wmv',
|
||||
'webm',
|
||||
'3gp',
|
||||
};
|
||||
|
||||
/// 录像文件浏览页:目录逐级下钻,视频点击全屏播放。
|
||||
class RecordListPage extends ConsumerWidget {
|
||||
const RecordListPage({
|
||||
super.key,
|
||||
required this.breadcrumbs,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// 面包屑,根页为 [赛事名],下钻时追加目录名。
|
||||
final List<String> breadcrumbs;
|
||||
final List<RecordListItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final visibleItems = items
|
||||
.where((item) => _isDirectory(item) || _isVideoFile(item))
|
||||
.toList(growable: false);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
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: [
|
||||
SizedBox(height: 4.h),
|
||||
Expanded(
|
||||
child: visibleItems.isEmpty
|
||||
? const AppEmptyView(message: '暂无录像内容')
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(10.w, 8.h, 10.w, 24.h),
|
||||
itemCount: visibleItems.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 8.h),
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
if (_isDirectory(item)) {
|
||||
return _DirectoryCard(
|
||||
item: item,
|
||||
onTap: () => _openDirectory(context, ref, item),
|
||||
);
|
||||
}
|
||||
return _VideoCard(
|
||||
item: item,
|
||||
onTap: () => _openVideo(context, item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openDirectory(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
RecordListItem item,
|
||||
) async {
|
||||
final path = item.path?.trim() ?? '';
|
||||
if (path.isEmpty) {
|
||||
AppToast.show('目录路径无效');
|
||||
return;
|
||||
}
|
||||
|
||||
EasyLoading.show(status: '加载中...');
|
||||
try {
|
||||
final children = await ref
|
||||
.read(authProvider.notifier)
|
||||
.fetchRecordList(path);
|
||||
EasyLoading.dismiss();
|
||||
if (children == null) {
|
||||
AppToast.show('目录加载失败,请重试');
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
final nextBreadcrumbs = [...breadcrumbs, item.name ?? ''];
|
||||
AppNavigator.push(
|
||||
RecordListPage(breadcrumbs: nextBreadcrumbs, items: children),
|
||||
context: context,
|
||||
// 多级目录复用同一页面类型,用路径区分路由,避免防重复拦截。
|
||||
name: 'RecordListPage-$path',
|
||||
);
|
||||
} catch (error) {
|
||||
EasyLoading.dismiss();
|
||||
AppToast.show('目录加载失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
void _openVideo(BuildContext context, RecordListItem item) {
|
||||
final url = resolveRecordUrl(item.url?.trim() ?? '');
|
||||
if (url.isEmpty) {
|
||||
AppToast.show('视频地址无效');
|
||||
return;
|
||||
}
|
||||
AppNavigator.push(
|
||||
RecordVideoPlayerPage(url: url, title: item.name ?? ''),
|
||||
context: context,
|
||||
name: 'RecordVideoPlayerPage-$url',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isDirectory(RecordListItem item) {
|
||||
return item.type == RecordItemType.directory.value;
|
||||
}
|
||||
|
||||
bool _isVideoFile(RecordListItem item) {
|
||||
if (item.type != RecordItemType.file.value) return false;
|
||||
var ext = (item.extension ?? '').toLowerCase();
|
||||
if (ext.startsWith('.')) ext = ext.substring(1);
|
||||
if (ext.isEmpty) {
|
||||
final name = item.name ?? '';
|
||||
final dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex >= 0 && dotIndex < name.length - 1) {
|
||||
ext = name.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
return _videoExtensions.contains(ext);
|
||||
}
|
||||
|
||||
/// 相对地址补全 NAS 前缀。
|
||||
String resolveRecordUrl(String raw) {
|
||||
if (raw.isEmpty) return '';
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
return raw.startsWith('/') ? '$_nasBaseUrl$raw' : '$_nasBaseUrl/$raw';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
return '${time.year}-${time.month}-${time.day}';
|
||||
}
|
||||
|
||||
class _DirectoryCard extends StatelessWidget {
|
||||
const _DirectoryCard({required this.item, required this.onTap});
|
||||
|
||||
final RecordListItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(item.modTime);
|
||||
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 72.h),
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 14.w, 12.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF30343A),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (date.isNotEmpty) ...[
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF6E747D),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoCard extends StatelessWidget {
|
||||
const _VideoCard({required this.item, required this.onTap});
|
||||
|
||||
final RecordListItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(item.modTime);
|
||||
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10.r),
|
||||
child: Row(
|
||||
children: [
|
||||
_VideoThumbnail(url: resolveRecordUrl(item.url?.trim() ?? '')),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.25,
|
||||
color: const Color(0xFF30343A),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (date.isNotEmpty) ...[
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF6E747D),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频首帧封面:异步生成缩略图,失败保留灰色占位,中央始终叠加播放图标。
|
||||
class _VideoThumbnail extends StatefulWidget {
|
||||
const _VideoThumbnail({required this.url});
|
||||
|
||||
final String url;
|
||||
|
||||
@override
|
||||
State<_VideoThumbnail> createState() => _VideoThumbnailState();
|
||||
}
|
||||
|
||||
class _VideoThumbnailState extends State<_VideoThumbnail> {
|
||||
/// 按 url 缓存首帧,避免列表滚动重建时重复生成。
|
||||
static final Map<String, Uint8List> _cache = {};
|
||||
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (widget.url.isEmpty) return;
|
||||
final cached = _cache[widget.url];
|
||||
if (cached != null) {
|
||||
_bytes = cached;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final data = await VideoThumbnail.thumbnailData(
|
||||
video: widget.url,
|
||||
imageFormat: ImageFormat.JPEG,
|
||||
maxWidth: 320,
|
||||
quality: 60,
|
||||
);
|
||||
_cache[widget.url] = data;
|
||||
if (!mounted) return;
|
||||
setState(() => _bytes = data);
|
||||
} catch (_) {
|
||||
// 生成失败保持占位图,不影响点击播放。
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
child: SizedBox(
|
||||
width: 108.w,
|
||||
height: 68.h,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: const Color(0xFFEDEFF2)),
|
||||
if (_bytes != null)
|
||||
Image.memory(_bytes!, fit: BoxFit.cover, gaplessPlayback: true),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 30.r,
|
||||
height: 30.r,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image(
|
||||
image: AssetImage(Assets.images.imageVideoIcon.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// 录像全屏播放页:黑色背景,支持播放/暂停、进度拖动。
|
||||
class RecordVideoPlayerPage extends StatefulWidget {
|
||||
const RecordVideoPlayerPage({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<RecordVideoPlayerPage> createState() => _RecordVideoPlayerPageState();
|
||||
}
|
||||
|
||||
class _RecordVideoPlayerPageState extends State<RecordVideoPlayerPage> {
|
||||
VideoPlayerController? _controller;
|
||||
bool _initialized = false;
|
||||
bool _hasError = false;
|
||||
bool _showControls = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initController();
|
||||
}
|
||||
|
||||
Future<void> _initController() async {
|
||||
setState(() {
|
||||
_hasError = false;
|
||||
_initialized = false;
|
||||
});
|
||||
|
||||
final old = _controller;
|
||||
_controller = null;
|
||||
await old?.dispose();
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
|
||||
_controller = controller;
|
||||
try {
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
setState(() => _initialized = true);
|
||||
await controller.play();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hasError = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _togglePlay() {
|
||||
final controller = _controller;
|
||||
if (controller == null || !_initialized) return;
|
||||
setState(() {
|
||||
if (controller.value.isPlaying) {
|
||||
controller.pause();
|
||||
} else {
|
||||
controller.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.black,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _showControls = !_showControls),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Center(child: _buildPlayer()),
|
||||
if (_showControls) _buildTopBar(),
|
||||
if (_showControls && _initialized) _buildBottomControls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayer() {
|
||||
if (_hasError) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'视频加载失败',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 15.sp),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
OutlinedButton(
|
||||
onPressed: _initController,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white54),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final controller = _controller;
|
||||
if (controller == null || !_initialized) {
|
||||
return const CircularProgressIndicator(color: Colors.white);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: controller.value.aspectRatio,
|
||||
child: VideoPlayer(controller),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => AppNavigator.pop(context: context),
|
||||
icon: Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
color: Colors.white,
|
||||
size: 30.r,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 48.w),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomControls() {
|
||||
final controller = _controller!;
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(12.w, 8.h, 12.w, 8.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
VideoProgressIndicator(
|
||||
controller,
|
||||
allowScrubbing: true,
|
||||
colors: const VideoProgressColors(
|
||||
playedColor: Colors.white,
|
||||
bufferedColor: Colors.white38,
|
||||
backgroundColor: Colors.white24,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||
),
|
||||
ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _togglePlay,
|
||||
icon: Icon(
|
||||
value.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 30.r,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_formatDuration(value.position)} / ${_formatDuration(value.duration)}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||
final hours = duration.inHours;
|
||||
if (hours > 0) return '$hours:$minutes:$seconds';
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
@@ -10,26 +10,13 @@ import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dar
|
||||
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';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/pages/page_record_list.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class ScanQrCodePage extends ConsumerStatefulWidget {
|
||||
const ScanQrCodePage({super.key});
|
||||
|
||||
static const mockRtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
|
||||
|
||||
static const mockRecordingContext = RecordingContext(
|
||||
eventTitle: '全国青少年无人机大赛',
|
||||
matchName: '空中格斗赛',
|
||||
group: '高中组',
|
||||
venue: '场地 1',
|
||||
time: '7月1日 12:00-15:00',
|
||||
playerName: '蔡依婷vs夏志豪',
|
||||
playerPhone: '',
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<ScanQrCodePage> createState() => _ScanQrCodePageState();
|
||||
}
|
||||
@@ -208,7 +195,13 @@ class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
|
||||
AppToast.show('暂无录像');
|
||||
return;
|
||||
}
|
||||
AppToast.show('录像列表已更新');
|
||||
if (!mounted) return;
|
||||
|
||||
final items = ref.read(authProvider).recordList ?? const [];
|
||||
AppNavigator.push(
|
||||
RecordListPage(breadcrumbs: [eventName.trim()], items: items),
|
||||
context: context,
|
||||
);
|
||||
} catch (error) {
|
||||
EasyLoading.dismiss();
|
||||
AppToast.show('查询录像失败');
|
||||
@@ -224,18 +217,41 @@ 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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user