1.完成查看录像模块功能

2.完成录像页面播放视频功能
This commit is contained in:
2026-07-28 16:36:32 +08:00
parent a324825e8a
commit 9fca76f679
7 changed files with 660 additions and 1 deletions
@@ -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<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(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<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(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<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: Icon(
Icons.play_arrow_rounded,
size: 22.r,
color: Colors.white,
),
),
),
],
),
),
);
}
}
@@ -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';
}
@@ -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<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('查询录像失败');