Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707

This commit is contained in:
2026-07-29 09:19:19 +08:00
21 changed files with 1041 additions and 396 deletions
@@ -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,
),
),
],
),
],
);