From 383462ef58cee4f3dbd200eb45201f526067d045 Mon Sep 17 00:00:00 2001 From: wuqifeng <540416539@qq.com> Date: Sun, 13 Sep 2026 11:14:55 +0800 Subject: [PATCH] feat: 新增视频专区列表与播放功能 --- assets/images/video_zone_entry.png | Bin 0 -> 508246 bytes lib/common/request/apis.dart | 5 +++++ lib/common/request/dao/video_dao.dart | 31 +++++++++++++++++++++++++++++++ lib/pages/home/view.dart | 32 +++++++++++++++++++++++++++++--- lib/pages/video/zone/video_zone_item.dart | 44 ++++++++++++++++++++++++++++++++++++++++++++ lib/pages/video/zone/video_zone_page.dart | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/pages/video/zone/video_zone_player_page.dart | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/route/route.dart | 20 ++++++++++++++++++++ 8 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 assets/images/video_zone_entry.png create mode 100644 lib/common/request/dao/video_dao.dart create mode 100644 lib/pages/video/zone/video_zone_item.dart create mode 100644 lib/pages/video/zone/video_zone_page.dart create mode 100644 lib/pages/video/zone/video_zone_player_page.dart diff --git a/assets/images/video_zone_entry.png b/assets/images/video_zone_entry.png new file mode 100644 index 0000000..d831893 Binary files /dev/null and b/assets/images/video_zone_entry.png differ diff --git a/lib/common/request/apis.dart b/lib/common/request/apis.dart index 986c202..f2a55da 100644 --- a/lib/common/request/apis.dart +++ b/lib/common/request/apis.dart @@ -110,4 +110,9 @@ class Apis { /// 用户反馈 static const String feedBack = 'student/feedback'; + /// 获取已上架的视频列表 + static const String videoList = 'video/list'; + + /// 保存视频播放记录 + static const String videoPlayRecord = 'video/play/record'; } diff --git a/lib/common/request/dao/video_dao.dart b/lib/common/request/dao/video_dao.dart new file mode 100644 index 0000000..3b6ac31 --- /dev/null +++ b/lib/common/request/dao/video_dao.dart @@ -0,0 +1,31 @@ +import 'package:wow_english/common/request/request_client.dart'; +import 'package:wow_english/pages/video/zone/video_zone_item.dart'; + +class VideoDao { + /// 获取已上架的视频列表。 + static Future> list() async { + final data = await requestClient.get>(Apis.videoList); + return (data ?? const []) + .whereType() + .map((item) => VideoZoneItem.fromJson(Map.from(item))) + .toList(); + } + + /// 保存视频播放记录。 + static Future savePlayRecord({ + required int videoId, + required int progressSeconds, + required int durationSeconds, + required bool completed, + }) async { + await requestClient.post( + Apis.videoPlayRecord, + data: { + 'videoId': videoId, + 'progressSeconds': progressSeconds, + 'durationSeconds': durationSeconds, + 'completed': completed, + }, + ); + } +} diff --git a/lib/pages/home/view.dart b/lib/pages/home/view.dart index 48dc98b..037468d 100644 --- a/lib/pages/home/view.dart +++ b/lib/pages/home/view.dart @@ -124,7 +124,28 @@ class _HomePageView extends StatelessWidget { ), ), ), - const Expanded(child: SizedBox.shrink()), + Expanded( + child: GestureDetector( + onTap: () => _checkPermission(() async { + await clickController.playMusicAndPerformAction( + context, AudioPlayerUtilType.videoTime, + () async { + await Navigator.of(context) + .pushNamed(AppRouteName.videoZone); + }); + }, bloc, + deniedMessage: '购买课程后即可进入视频专区。', + deniedActionText: '去购买'), + child: Center( + child: Image.asset( + 'video_zone_entry'.assetPng, + width: 153.w, + height: 176.h, + fit: BoxFit.contain, + ), + ), + ), + ), Expanded( child: BlocBuilder( builder: (context, userState) { @@ -209,7 +230,12 @@ class _HomePageView extends StatelessWidget { } } - _checkPermission(VoidCallback onAllowed, HomeBloc bloc) { + _checkPermission( + VoidCallback onAllowed, + HomeBloc bloc, { + String deniedMessage = '您的课程已到期,请快快续费继续学习吧!', + String deniedActionText = '去续费', + }) { if (UserUtil.isLogined()) { if (AppConfigHelper.shouldHidePay()) { onAllowed(); @@ -217,7 +243,7 @@ class _HomePageView extends StatelessWidget { if (UserUtil.hasPermission()) { onAllowed(); } else { - showTwoActionDialog('提示', '忽略', '去续费', '您的课程已到期,请快快续费继续学习吧!', + showTwoActionDialog('提示', '忽略', deniedActionText, deniedMessage, leftTap: () { popPage(); }, rightTap: () { diff --git a/lib/pages/video/zone/video_zone_item.dart b/lib/pages/video/zone/video_zone_item.dart new file mode 100644 index 0000000..2e793cd --- /dev/null +++ b/lib/pages/video/zone/video_zone_item.dart @@ -0,0 +1,44 @@ +class VideoZoneItem { + const VideoZoneItem({ + required this.id, + required this.name, + required this.coverUrl, + required this.videoUrl, + required this.accessible, + required this.completed, + required this.durationSeconds, + required this.progressSeconds, + required this.lastPlayTime, + }); + + factory VideoZoneItem.fromJson(Map json) { + int toInt(dynamic value) => + value is num ? value.toInt() : int.tryParse('$value') ?? 0; + bool toBool(dynamic value) => + value == true || value == 1 || value?.toString() == 'true'; + + return VideoZoneItem( + id: toInt(json['id']), + name: json['name']?.toString() ?? '', + coverUrl: json['coverUrl']?.toString() ?? '', + videoUrl: json['videoUrl']?.toString() ?? '', + accessible: toBool(json['accessible']), + completed: toBool(json['completed']), + durationSeconds: toInt(json['durationSeconds']), + progressSeconds: toInt(json['progressSeconds']), + lastPlayTime: json['lastPlayTime']?.toString() ?? '', + ); + } + + final int id; + final String name; + final String coverUrl; + final String videoUrl; + final bool accessible; + final bool completed; + final int durationSeconds; + final int progressSeconds; + final String lastPlayTime; + + String get title => name; +} diff --git a/lib/pages/video/zone/video_zone_page.dart b/lib/pages/video/zone/video_zone_page.dart new file mode 100644 index 0000000..cf2d6bf --- /dev/null +++ b/lib/pages/video/zone/video_zone_page.dart @@ -0,0 +1,186 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:wow_english/common/dialogs/show_dialog.dart'; +import 'package:wow_english/common/request/dao/video_dao.dart'; +import 'package:wow_english/common/widgets/we_app_bar.dart'; +import 'package:wow_english/route/route.dart'; + +import 'video_zone_item.dart'; + +class VideoZonePage extends StatefulWidget { + const VideoZonePage({super.key}); + + @override + State createState() => _VideoZonePageState(); +} + +class _VideoZonePageState extends State { + late Future> _itemsFuture; + + @override + void initState() { + super.initState(); + _itemsFuture = VideoDao.list(); + } + + void _reload() { + setState(() => _itemsFuture = VideoDao.list()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: const WEAppBar( + titleText: '视频专区', + centerTitle: false, + ), + body: FutureBuilder>( + future: _itemsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _VideoLoadError(onRetry: _reload); + } + final items = snapshot.data ?? const []; + if (items.isEmpty) { + return const Center( + child: Text( + '暂无视频', + style: TextStyle(color: Color(0xFF999999), fontSize: 18), + ), + ); + } + return RefreshIndicator( + onRefresh: () async { + final future = VideoDao.list(); + setState(() => _itemsFuture = future); + await future; + }, + child: GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 16.h), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 14.h, + crossAxisSpacing: 12.w, + childAspectRatio: 1.55, + ), + itemCount: items.length, + itemBuilder: (context, index) => _VideoCard( + item: items[index], + onTap: () => _openVideo(items, index), + ), + ), + ); + }, + ), + ); + } + + void _openVideo(List items, int index) { + final item = items[index]; + if (!item.accessible || item.videoUrl.isEmpty) { + showTwoActionDialog('提示', '取消', '去购买', '购买课程后即可观看视频。', leftTap: popPage, + rightTap: () { + popPage(); + pushNamed(AppRouteName.shop); + }); + return; + } + Navigator.of(context).pushNamed( + AppRouteName.videoZonePlayer, + arguments: { + 'items': items + .where((video) => video.accessible && video.videoUrl.isNotEmpty) + .toList(), + 'initialVideoId': item.id, + }, + ).then((_) => _reload()); + } +} + +class _VideoLoadError extends StatelessWidget { + const _VideoLoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '视频加载失败,请稍后重试', + style: TextStyle(color: Color(0xFF999999), fontSize: 18), + ), + const SizedBox(height: 12), + FilledButton(onPressed: onRetry, child: const Text('重新加载')), + ], + ), + ); + } +} + +class _VideoCard extends StatelessWidget { + const _VideoCard({required this.item, required this.onTap}); + + final VideoZoneItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(8.r), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8.r), + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: item.coverUrl, + fit: BoxFit.cover, + placeholder: (_, __) => const ColoredBox( + color: Color(0xFFF2F2F2), + child: Center(child: CircularProgressIndicator()), + ), + errorWidget: (_, __, ___) => const ColoredBox( + color: Color(0xFFF2F2F2), + child: Icon(Icons.broken_image_outlined, + color: Color(0xFFAAAAAA)), + ), + ), + const Center( + child: Icon(Icons.play_circle_fill, + size: 42, color: Colors.white), + ), + ], + ), + ), + ), + SizedBox(height: 5.h), + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFF222222), + fontFamily: null, + fontSize: 17.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/video/zone/video_zone_player_page.dart b/lib/pages/video/zone/video_zone_player_page.dart new file mode 100644 index 0000000..cfdd940 --- /dev/null +++ b/lib/pages/video/zone/video_zone_player_page.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wow_english/common/request/dao/video_dao.dart'; + +import 'video_zone_item.dart'; + +class VideoZonePlayerPage extends StatefulWidget { + const VideoZonePlayerPage({ + super.key, + required this.items, + this.initialVideoId, + }) : assert(items.length > 0, '视频播放列表不能为空'); + + final List items; + final int? initialVideoId; + + @override + State createState() => _VideoZonePlayerPageState(); +} + +class _VideoZonePlayerPageState extends State + with WidgetsBindingObserver { + VideoPlayerController? _controller; + Timer? _progressTimer; + late int _currentIndex; + double _playbackSpeed = 1; + bool _showControls = true; + bool _switchingVideo = false; + bool _handlingCompletion = false; + int? _lastReportedPosition; + int? _completedReportedVideoId; + Object? _loadError; + + VideoZoneItem get _currentItem => widget.items[_currentIndex]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + final index = widget.initialVideoId == null + ? 0 + : widget.items.indexWhere((item) => item.id == widget.initialVideoId); + _currentIndex = index < 0 ? 0 : index; + _loadCurrentVideo(); + } + + Future _loadCurrentVideo() async { + _progressTimer?.cancel(); + final oldController = _controller; + oldController?.removeListener(_onVideoChanged); + final controller = + VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); + _controller = controller; + _loadError = null; + if (mounted) { + setState(() {}); + } + await oldController?.dispose(); + + try { + await controller.initialize(); + if (!mounted || controller != _controller) { + await controller.dispose(); + return; + } + await controller.setLooping(false); + await controller.setPlaybackSpeed(_playbackSpeed); + final savedProgress = + _currentItem.completed ? 0 : _currentItem.progressSeconds; + if (savedProgress > 0 && + savedProgress < controller.value.duration.inSeconds) { + await controller.seekTo(Duration(seconds: savedProgress)); + } + controller.addListener(_onVideoChanged); + await controller.play(); + _lastReportedPosition = null; + _handlingCompletion = false; + _progressTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _reportCurrentProgress(), + ); + setState(() {}); + } catch (error) { + if (mounted && controller == _controller) { + setState(() => _loadError = error); + } + } + } + + void _onVideoChanged() { + final controller = _controller; + if (!mounted || controller == null) return; + if (controller.value.isCompleted && + !_switchingVideo && + !_handlingCompletion && + _completedReportedVideoId != _currentItem.id) { + _handlingCompletion = true; + unawaited(_handleVideoCompleted()); + return; + } + setState(() {}); + } + + Future _playNext({bool currentCompleted = false}) async { + if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; + _switchingVideo = true; + await _reportCurrentProgress(completed: currentCompleted, force: true); + setState(() => _currentIndex++); + await _loadCurrentVideo(); + _switchingVideo = false; + } + + Future _handleVideoCompleted() async { + if (_currentIndex < widget.items.length - 1) { + await _playNext(currentCompleted: true); + } else { + await _reportCurrentProgress(completed: true, force: true); + } + _handlingCompletion = false; + } + + Future _togglePlay() async { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + if (controller.value.isCompleted) { + await controller.seekTo(Duration.zero); + _completedReportedVideoId = null; + } + if (controller.value.isPlaying) { + await controller.pause(); + await _reportCurrentProgress(force: true); + } else { + await controller.play(); + } + if (mounted) setState(() {}); + } + + Future _setSpeed(double speed) async { + _playbackSpeed = speed; + await _controller?.setPlaybackSpeed(speed); + if (mounted) setState(() {}); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _progressTimer?.cancel(); + _controller?.removeListener(_onVideoChanged); + unawaited(_reportCurrentProgress(force: true)); + _controller?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + unawaited(_reportCurrentProgress(force: true)); + } + } + + Future _reportCurrentProgress({ + bool? completed, + bool force = false, + }) async { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + final durationSeconds = controller.value.duration.inSeconds > 0 + ? controller.value.duration.inSeconds + : _currentItem.durationSeconds; + final progressSeconds = controller.value.position.inSeconds + .clamp(0, durationSeconds > 0 ? durationSeconds : 0); + final isCompleted = completed ?? controller.value.isCompleted; + if (!force && _lastReportedPosition == progressSeconds) return; + if (isCompleted && _completedReportedVideoId == _currentItem.id) return; + _lastReportedPosition = progressSeconds; + try { + await VideoDao.savePlayRecord( + videoId: _currentItem.id, + progressSeconds: progressSeconds, + durationSeconds: durationSeconds, + completed: isCompleted, + ); + if (isCompleted) _completedReportedVideoId = _currentItem.id; + } catch (error) { + debugPrint('保存视频播放记录失败: $error'); + } + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Scaffold( + backgroundColor: Colors.black, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _showControls = !_showControls), + onDoubleTap: _togglePlay, + child: Stack( + fit: StackFit.expand, + children: [ + if (controller?.value.isInitialized == true) + Center( + child: AspectRatio( + aspectRatio: controller!.value.aspectRatio, + child: VideoPlayer(controller), + ), + ) + else if (_loadError != null) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('视频加载失败', + style: TextStyle(color: Colors.white, fontSize: 18)), + const SizedBox(height: 12), + FilledButton( + onPressed: _loadCurrentVideo, child: const Text('重试')), + ], + ), + ) + else + const Center(child: CircularProgressIndicator()), + if (_showControls) _buildControls(controller), + ], + ), + ), + ); + } + + Widget _buildControls(VideoPlayerController? controller) { + final initialized = controller?.value.isInitialized == true; + return ColoredBox( + color: Colors.black38, + child: SafeArea( + child: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: + const Icon(Icons.arrow_back_ios_new, color: Colors.white), + ), + Expanded( + child: Text( + _currentItem.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 20), + ), + ), + PopupMenuButton( + initialValue: _playbackSpeed, + onSelected: _setSpeed, + itemBuilder: (_) => const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] + .map((speed) => PopupMenuItem( + value: speed, + child: Text('${speed}x'), + )) + .toList(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, vertical: 12), + child: Text('${_playbackSpeed}x', + style: + const TextStyle(color: Colors.white, fontSize: 16)), + ), + ), + ], + ), + const Spacer(), + if (initialized) + IconButton( + onPressed: _togglePlay, + iconSize: 68, + color: Colors.white, + icon: Icon(controller!.value.isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_fill), + ), + const Spacer(), + if (initialized) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Expanded( + child: VideoProgressIndicator( + controller!, + allowScrubbing: true, + padding: const EdgeInsets.symmetric(vertical: 12), + colors: const VideoProgressColors( + playedColor: Color(0xFF00B6F1), + bufferedColor: Colors.white54, + backgroundColor: Colors.white24, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${_formatDuration(controller.value.position)} / ' + '${_formatDuration(controller.value.duration)}', + style: const TextStyle(color: Colors.white), + ), + if (_currentIndex < widget.items.length - 1) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: '下一集', + onPressed: _playNext, + icon: const Icon(Icons.skip_next, color: Colors.white), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (duration.inHours == 0) return '$minutes:$seconds'; + return '${duration.inHours.toString().padLeft(2, '0')}:$minutes:$seconds'; + } +} diff --git a/lib/route/route.dart b/lib/route/route.dart index 1e998f2..cdc460b 100644 --- a/lib/route/route.dart +++ b/lib/route/route.dart @@ -25,6 +25,9 @@ import 'package:wow_english/pages/user/modify/modify_user_information_page.dart' import 'package:wow_english/pages/user/setting/setting_page.dart'; import 'package:wow_english/pages/user/user_page.dart'; import 'package:wow_english/pages/video/lookvideo/look_video_page.dart'; +import 'package:wow_english/pages/video/zone/video_zone_item.dart'; +import 'package:wow_english/pages/video/zone/video_zone_page.dart'; +import 'package:wow_english/pages/video/zone/video_zone_player_page.dart'; import '../models/course_module_entity.dart'; import '../pages/reading/reading_page.dart'; @@ -69,6 +72,12 @@ class AppRouteName { ///看视频 static const String lookVideo = 'lookVideo'; + ///视频专区 + static const String videoZone = 'videoZone'; + + ///视频专区播放器 + static const String videoZonePlayer = 'videoZonePlayer'; + ///绘本 static const String reading = 'reading'; @@ -208,6 +217,17 @@ class AppRouter { courseLessonId: courseLessonId, isTopic: isTopic, )); + case AppRouteName.videoZone: + return CupertinoPageRoute(builder: (_) => const VideoZonePage()); + case AppRouteName.videoZonePlayer: + final arguments = settings.arguments as Map; + final items = arguments['items'] as List; + final initialVideoId = arguments['initialVideoId'] as int?; + return CupertinoPageRoute( + builder: (_) => VideoZonePlayerPage( + items: items, + initialVideoId: initialVideoId, + )); /*case AppRouteName.setPwd: case AppRouteName.setPwd: phoneNum: phoneNum, -- libgit2 0.22.2