1.完成查看录像模块功能
2.完成录像页面播放视频功能
This commit is contained in:
@@ -45,6 +45,14 @@ class GetRecordListResModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RecordItemType {
|
||||||
|
file('file'),
|
||||||
|
directory('dir');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const RecordItemType(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
class RecordListItem {
|
class RecordListItem {
|
||||||
String? name;
|
String? name;
|
||||||
String? path;
|
String? path;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AuthState {
|
|||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
|
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
|
||||||
|
recordList: recordList ?? this.recordList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/app_storage.dart';
|
||||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||||
import 'package:recording_tool/core/network/api_exception.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/model/model_jwt.dart';
|
||||||
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
||||||
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
||||||
@@ -72,6 +73,13 @@ class AuthViewModel extends StateNotifier<AuthState> {
|
|||||||
return true;
|
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 + 内存状态)
|
/// 清空授权信息(本地 token + 内存状态)
|
||||||
Future<void> clearAuth() async {
|
Future<void> clearAuth() async {
|
||||||
await AppStorage.remove(StorageKeys.authToken);
|
await AppStorage.remove(StorageKeys.authToken);
|
||||||
|
|||||||
@@ -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/model/model_event_info.dart';
|
||||||
import 'package:recording_tool/features/events/pages/page_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/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_qr_scanner_dialog.dart';
|
||||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||||
|
|
||||||
@@ -195,7 +196,13 @@ class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
|
|||||||
AppToast.show('暂无录像');
|
AppToast.show('暂无录像');
|
||||||
return;
|
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) {
|
} catch (error) {
|
||||||
EasyLoading.dismiss();
|
EasyLoading.dismiss();
|
||||||
AppToast.show('查询录像失败');
|
AppToast.show('查询录像失败');
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ dependencies:
|
|||||||
path: plugins/apivideo_live_stream
|
path: plugins/apivideo_live_stream
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
network_info_plus: ^7.0.0
|
network_info_plus: ^7.0.0
|
||||||
|
video_player: ^2.13.0
|
||||||
|
get_thumbnail_video: ^0.7.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user