From 9fca76f679e0357a4bd654425190accb69a83d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Tue, 28 Jul 2026 16:36:32 +0800 Subject: [PATCH] =?UTF-8?q?1.=E5=AE=8C=E6=88=90=E6=9F=A5=E7=9C=8B=E5=BD=95?= =?UTF-8?q?=E5=83=8F=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD=202.=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=BD=95=E5=83=8F=E9=A1=B5=E9=9D=A2=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/auth/model/model_auth.dart | 8 + lib/features/auth/state/state_auth.dart | 1 + .../auth/view_model_auth/view_model_auth.dart | 8 + .../scan_qrcode/pages/page_record_list.dart | 383 ++++++++++++++++++ .../pages/page_record_video_player.dart | 250 ++++++++++++ .../scan_qrcode/pages/page_scan_qrcode.dart | 9 +- pubspec.yaml | 2 + 7 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 lib/features/scan_qrcode/pages/page_record_list.dart create mode 100644 lib/features/scan_qrcode/pages/page_record_video_player.dart diff --git a/lib/features/auth/model/model_auth.dart b/lib/features/auth/model/model_auth.dart index e893150..a380c33 100644 --- a/lib/features/auth/model/model_auth.dart +++ b/lib/features/auth/model/model_auth.dart @@ -45,6 +45,14 @@ class GetRecordListResModel { }; } +enum RecordItemType { + file('file'), + directory('dir'); + + final String value; + const RecordItemType(this.value); +} + class RecordListItem { String? name; String? path; diff --git a/lib/features/auth/state/state_auth.dart b/lib/features/auth/state/state_auth.dart index 31b1251..e502897 100644 --- a/lib/features/auth/state/state_auth.dart +++ b/lib/features/auth/state/state_auth.dart @@ -28,6 +28,7 @@ class AuthState { isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage ?? this.errorMessage, jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData, + recordList: recordList ?? this.recordList, ); } } diff --git a/lib/features/auth/view_model_auth/view_model_auth.dart b/lib/features/auth/view_model_auth/view_model_auth.dart index a36ca80..3beb76a 100644 --- a/lib/features/auth/view_model_auth/view_model_auth.dart +++ b/lib/features/auth/view_model_auth/view_model_auth.dart @@ -5,6 +5,7 @@ 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'; @@ -72,6 +73,13 @@ class AuthViewModel extends StateNotifier { return true; } + /// 获取指定目录的录像列表(不更新 state,用于目录下钻) + Future?> fetchRecordList(String path) async { + if (path.isEmpty) return null; + final data = await AuthServer.getRecordList(_ref, path); + return data?.items; + } + /// 清空授权信息(本地 token + 内存状态) Future clearAuth() async { await AppStorage.remove(StorageKeys.authToken); diff --git a/lib/features/scan_qrcode/pages/page_record_list.dart b/lib/features/scan_qrcode/pages/page_record_list.dart new file mode 100644 index 0000000..e004310 --- /dev/null +++ b/lib/features/scan_qrcode/pages/page_record_list.dart @@ -0,0 +1,383 @@ +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/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 breadcrumbs; + final List 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(title: '查看录像'), + 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, + ), + ), + ), + 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 _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(12.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), + 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 _cache = {}; + + Uint8List? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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: Icon( + Icons.play_arrow_rounded, + size: 22.r, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/scan_qrcode/pages/page_record_video_player.dart b/lib/features/scan_qrcode/pages/page_record_video_player.dart new file mode 100644 index 0000000..7afe19e --- /dev/null +++ b/lib/features/scan_qrcode/pages/page_record_video_player.dart @@ -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 createState() => _RecordVideoPlayerPageState(); +} + +class _RecordVideoPlayerPageState extends State { + VideoPlayerController? _controller; + bool _initialized = false; + bool _hasError = false; + bool _showControls = true; + + @override + void initState() { + super.initState(); + _initController(); + } + + Future _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( + 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( + 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'; +} diff --git a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart index 3efa2b6..4f42ae0 100644 --- a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart +++ b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart @@ -11,6 +11,7 @@ import 'package:recording_tool/features/competition_teams/pages/page_competition 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/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'; @@ -195,7 +196,13 @@ class _ScanQrCodePageState extends ConsumerState { 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('查询录像失败'); diff --git a/pubspec.yaml b/pubspec.yaml index 47952c1..261d5c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,8 @@ dependencies: path: plugins/apivideo_live_stream jwt_decoder: ^2.0.1 network_info_plus: ^7.0.0 + video_player: ^2.13.0 + get_thumbnail_video: ^0.7.3 dev_dependencies: flutter_test: