Commit 383462ef58cee4f3dbd200eb45201f526067d045
1 parent
9d058cb4
feat: 新增视频专区列表与播放功能
Showing
8 changed files
with
647 additions
and
3 deletions
assets/images/video_zone_entry.png
0 → 100644
496 KB
lib/common/request/apis.dart
| @@ -110,4 +110,9 @@ class Apis { | @@ -110,4 +110,9 @@ class Apis { | ||
| 110 | /// 用户反馈 | 110 | /// 用户反馈 |
| 111 | static const String feedBack = 'student/feedback'; | 111 | static const String feedBack = 'student/feedback'; |
| 112 | 112 | ||
| 113 | + /// 获取已上架的视频列表 | ||
| 114 | + static const String videoList = 'video/list'; | ||
| 115 | + | ||
| 116 | + /// 保存视频播放记录 | ||
| 117 | + static const String videoPlayRecord = 'video/play/record'; | ||
| 113 | } | 118 | } |
lib/common/request/dao/video_dao.dart
0 → 100644
| 1 | +import 'package:wow_english/common/request/request_client.dart'; | ||
| 2 | +import 'package:wow_english/pages/video/zone/video_zone_item.dart'; | ||
| 3 | + | ||
| 4 | +class VideoDao { | ||
| 5 | + /// 获取已上架的视频列表。 | ||
| 6 | + static Future<List<VideoZoneItem>> list() async { | ||
| 7 | + final data = await requestClient.get<List<dynamic>>(Apis.videoList); | ||
| 8 | + return (data ?? const <dynamic>[]) | ||
| 9 | + .whereType<Map>() | ||
| 10 | + .map((item) => VideoZoneItem.fromJson(Map<String, dynamic>.from(item))) | ||
| 11 | + .toList(); | ||
| 12 | + } | ||
| 13 | + | ||
| 14 | + /// 保存视频播放记录。 | ||
| 15 | + static Future<void> savePlayRecord({ | ||
| 16 | + required int videoId, | ||
| 17 | + required int progressSeconds, | ||
| 18 | + required int durationSeconds, | ||
| 19 | + required bool completed, | ||
| 20 | + }) async { | ||
| 21 | + await requestClient.post( | ||
| 22 | + Apis.videoPlayRecord, | ||
| 23 | + data: { | ||
| 24 | + 'videoId': videoId, | ||
| 25 | + 'progressSeconds': progressSeconds, | ||
| 26 | + 'durationSeconds': durationSeconds, | ||
| 27 | + 'completed': completed, | ||
| 28 | + }, | ||
| 29 | + ); | ||
| 30 | + } | ||
| 31 | +} |
lib/pages/home/view.dart
| @@ -124,7 +124,28 @@ class _HomePageView extends StatelessWidget { | @@ -124,7 +124,28 @@ class _HomePageView extends StatelessWidget { | ||
| 124 | ), | 124 | ), |
| 125 | ), | 125 | ), |
| 126 | ), | 126 | ), |
| 127 | - const Expanded(child: SizedBox.shrink()), | 127 | + Expanded( |
| 128 | + child: GestureDetector( | ||
| 129 | + onTap: () => _checkPermission(() async { | ||
| 130 | + await clickController.playMusicAndPerformAction( | ||
| 131 | + context, AudioPlayerUtilType.videoTime, | ||
| 132 | + () async { | ||
| 133 | + await Navigator.of(context) | ||
| 134 | + .pushNamed(AppRouteName.videoZone); | ||
| 135 | + }); | ||
| 136 | + }, bloc, | ||
| 137 | + deniedMessage: '购买课程后即可进入视频专区。', | ||
| 138 | + deniedActionText: '去购买'), | ||
| 139 | + child: Center( | ||
| 140 | + child: Image.asset( | ||
| 141 | + 'video_zone_entry'.assetPng, | ||
| 142 | + width: 153.w, | ||
| 143 | + height: 176.h, | ||
| 144 | + fit: BoxFit.contain, | ||
| 145 | + ), | ||
| 146 | + ), | ||
| 147 | + ), | ||
| 148 | + ), | ||
| 128 | Expanded( | 149 | Expanded( |
| 129 | child: BlocBuilder<UserBloc, UserState>( | 150 | child: BlocBuilder<UserBloc, UserState>( |
| 130 | builder: (context, userState) { | 151 | builder: (context, userState) { |
| @@ -209,7 +230,12 @@ class _HomePageView extends StatelessWidget { | @@ -209,7 +230,12 @@ class _HomePageView extends StatelessWidget { | ||
| 209 | } | 230 | } |
| 210 | } | 231 | } |
| 211 | 232 | ||
| 212 | - _checkPermission(VoidCallback onAllowed, HomeBloc bloc) { | 233 | + _checkPermission( |
| 234 | + VoidCallback onAllowed, | ||
| 235 | + HomeBloc bloc, { | ||
| 236 | + String deniedMessage = '您的课程已到期,请快快续费继续学习吧!', | ||
| 237 | + String deniedActionText = '去续费', | ||
| 238 | + }) { | ||
| 213 | if (UserUtil.isLogined()) { | 239 | if (UserUtil.isLogined()) { |
| 214 | if (AppConfigHelper.shouldHidePay()) { | 240 | if (AppConfigHelper.shouldHidePay()) { |
| 215 | onAllowed(); | 241 | onAllowed(); |
| @@ -217,7 +243,7 @@ class _HomePageView extends StatelessWidget { | @@ -217,7 +243,7 @@ class _HomePageView extends StatelessWidget { | ||
| 217 | if (UserUtil.hasPermission()) { | 243 | if (UserUtil.hasPermission()) { |
| 218 | onAllowed(); | 244 | onAllowed(); |
| 219 | } else { | 245 | } else { |
| 220 | - showTwoActionDialog('提示', '忽略', '去续费', '您的课程已到期,请快快续费继续学习吧!', | 246 | + showTwoActionDialog('提示', '忽略', deniedActionText, deniedMessage, |
| 221 | leftTap: () { | 247 | leftTap: () { |
| 222 | popPage(); | 248 | popPage(); |
| 223 | }, rightTap: () { | 249 | }, rightTap: () { |
lib/pages/video/zone/video_zone_item.dart
0 → 100644
| 1 | +class VideoZoneItem { | ||
| 2 | + const VideoZoneItem({ | ||
| 3 | + required this.id, | ||
| 4 | + required this.name, | ||
| 5 | + required this.coverUrl, | ||
| 6 | + required this.videoUrl, | ||
| 7 | + required this.accessible, | ||
| 8 | + required this.completed, | ||
| 9 | + required this.durationSeconds, | ||
| 10 | + required this.progressSeconds, | ||
| 11 | + required this.lastPlayTime, | ||
| 12 | + }); | ||
| 13 | + | ||
| 14 | + factory VideoZoneItem.fromJson(Map<String, dynamic> json) { | ||
| 15 | + int toInt(dynamic value) => | ||
| 16 | + value is num ? value.toInt() : int.tryParse('$value') ?? 0; | ||
| 17 | + bool toBool(dynamic value) => | ||
| 18 | + value == true || value == 1 || value?.toString() == 'true'; | ||
| 19 | + | ||
| 20 | + return VideoZoneItem( | ||
| 21 | + id: toInt(json['id']), | ||
| 22 | + name: json['name']?.toString() ?? '', | ||
| 23 | + coverUrl: json['coverUrl']?.toString() ?? '', | ||
| 24 | + videoUrl: json['videoUrl']?.toString() ?? '', | ||
| 25 | + accessible: toBool(json['accessible']), | ||
| 26 | + completed: toBool(json['completed']), | ||
| 27 | + durationSeconds: toInt(json['durationSeconds']), | ||
| 28 | + progressSeconds: toInt(json['progressSeconds']), | ||
| 29 | + lastPlayTime: json['lastPlayTime']?.toString() ?? '', | ||
| 30 | + ); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + final int id; | ||
| 34 | + final String name; | ||
| 35 | + final String coverUrl; | ||
| 36 | + final String videoUrl; | ||
| 37 | + final bool accessible; | ||
| 38 | + final bool completed; | ||
| 39 | + final int durationSeconds; | ||
| 40 | + final int progressSeconds; | ||
| 41 | + final String lastPlayTime; | ||
| 42 | + | ||
| 43 | + String get title => name; | ||
| 44 | +} |
lib/pages/video/zone/video_zone_page.dart
0 → 100644
| 1 | +import 'package:cached_network_image/cached_network_image.dart'; | ||
| 2 | +import 'package:flutter/material.dart'; | ||
| 3 | +import 'package:flutter_screenutil/flutter_screenutil.dart'; | ||
| 4 | +import 'package:wow_english/common/dialogs/show_dialog.dart'; | ||
| 5 | +import 'package:wow_english/common/request/dao/video_dao.dart'; | ||
| 6 | +import 'package:wow_english/common/widgets/we_app_bar.dart'; | ||
| 7 | +import 'package:wow_english/route/route.dart'; | ||
| 8 | + | ||
| 9 | +import 'video_zone_item.dart'; | ||
| 10 | + | ||
| 11 | +class VideoZonePage extends StatefulWidget { | ||
| 12 | + const VideoZonePage({super.key}); | ||
| 13 | + | ||
| 14 | + @override | ||
| 15 | + State<VideoZonePage> createState() => _VideoZonePageState(); | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +class _VideoZonePageState extends State<VideoZonePage> { | ||
| 19 | + late Future<List<VideoZoneItem>> _itemsFuture; | ||
| 20 | + | ||
| 21 | + @override | ||
| 22 | + void initState() { | ||
| 23 | + super.initState(); | ||
| 24 | + _itemsFuture = VideoDao.list(); | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + void _reload() { | ||
| 28 | + setState(() => _itemsFuture = VideoDao.list()); | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @override | ||
| 32 | + Widget build(BuildContext context) { | ||
| 33 | + return Scaffold( | ||
| 34 | + backgroundColor: Colors.white, | ||
| 35 | + appBar: const WEAppBar( | ||
| 36 | + titleText: '视频专区', | ||
| 37 | + centerTitle: false, | ||
| 38 | + ), | ||
| 39 | + body: FutureBuilder<List<VideoZoneItem>>( | ||
| 40 | + future: _itemsFuture, | ||
| 41 | + builder: (context, snapshot) { | ||
| 42 | + if (snapshot.connectionState == ConnectionState.waiting) { | ||
| 43 | + return const Center(child: CircularProgressIndicator()); | ||
| 44 | + } | ||
| 45 | + if (snapshot.hasError) { | ||
| 46 | + return _VideoLoadError(onRetry: _reload); | ||
| 47 | + } | ||
| 48 | + final items = snapshot.data ?? const <VideoZoneItem>[]; | ||
| 49 | + if (items.isEmpty) { | ||
| 50 | + return const Center( | ||
| 51 | + child: Text( | ||
| 52 | + '暂无视频', | ||
| 53 | + style: TextStyle(color: Color(0xFF999999), fontSize: 18), | ||
| 54 | + ), | ||
| 55 | + ); | ||
| 56 | + } | ||
| 57 | + return RefreshIndicator( | ||
| 58 | + onRefresh: () async { | ||
| 59 | + final future = VideoDao.list(); | ||
| 60 | + setState(() => _itemsFuture = future); | ||
| 61 | + await future; | ||
| 62 | + }, | ||
| 63 | + child: GridView.builder( | ||
| 64 | + physics: const AlwaysScrollableScrollPhysics(), | ||
| 65 | + padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 16.h), | ||
| 66 | + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( | ||
| 67 | + crossAxisCount: 4, | ||
| 68 | + mainAxisSpacing: 14.h, | ||
| 69 | + crossAxisSpacing: 12.w, | ||
| 70 | + childAspectRatio: 1.55, | ||
| 71 | + ), | ||
| 72 | + itemCount: items.length, | ||
| 73 | + itemBuilder: (context, index) => _VideoCard( | ||
| 74 | + item: items[index], | ||
| 75 | + onTap: () => _openVideo(items, index), | ||
| 76 | + ), | ||
| 77 | + ), | ||
| 78 | + ); | ||
| 79 | + }, | ||
| 80 | + ), | ||
| 81 | + ); | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + void _openVideo(List<VideoZoneItem> items, int index) { | ||
| 85 | + final item = items[index]; | ||
| 86 | + if (!item.accessible || item.videoUrl.isEmpty) { | ||
| 87 | + showTwoActionDialog('提示', '取消', '去购买', '购买课程后即可观看视频。', leftTap: popPage, | ||
| 88 | + rightTap: () { | ||
| 89 | + popPage(); | ||
| 90 | + pushNamed(AppRouteName.shop); | ||
| 91 | + }); | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + Navigator.of(context).pushNamed( | ||
| 95 | + AppRouteName.videoZonePlayer, | ||
| 96 | + arguments: { | ||
| 97 | + 'items': items | ||
| 98 | + .where((video) => video.accessible && video.videoUrl.isNotEmpty) | ||
| 99 | + .toList(), | ||
| 100 | + 'initialVideoId': item.id, | ||
| 101 | + }, | ||
| 102 | + ).then((_) => _reload()); | ||
| 103 | + } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +class _VideoLoadError extends StatelessWidget { | ||
| 107 | + const _VideoLoadError({required this.onRetry}); | ||
| 108 | + | ||
| 109 | + final VoidCallback onRetry; | ||
| 110 | + | ||
| 111 | + @override | ||
| 112 | + Widget build(BuildContext context) { | ||
| 113 | + return Center( | ||
| 114 | + child: Column( | ||
| 115 | + mainAxisSize: MainAxisSize.min, | ||
| 116 | + children: [ | ||
| 117 | + const Text( | ||
| 118 | + '视频加载失败,请稍后重试', | ||
| 119 | + style: TextStyle(color: Color(0xFF999999), fontSize: 18), | ||
| 120 | + ), | ||
| 121 | + const SizedBox(height: 12), | ||
| 122 | + FilledButton(onPressed: onRetry, child: const Text('重新加载')), | ||
| 123 | + ], | ||
| 124 | + ), | ||
| 125 | + ); | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +class _VideoCard extends StatelessWidget { | ||
| 130 | + const _VideoCard({required this.item, required this.onTap}); | ||
| 131 | + | ||
| 132 | + final VideoZoneItem item; | ||
| 133 | + final VoidCallback onTap; | ||
| 134 | + | ||
| 135 | + @override | ||
| 136 | + Widget build(BuildContext context) { | ||
| 137 | + return InkWell( | ||
| 138 | + borderRadius: BorderRadius.circular(8.r), | ||
| 139 | + onTap: onTap, | ||
| 140 | + child: Column( | ||
| 141 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 142 | + children: [ | ||
| 143 | + Expanded( | ||
| 144 | + child: ClipRRect( | ||
| 145 | + borderRadius: BorderRadius.circular(8.r), | ||
| 146 | + child: Stack( | ||
| 147 | + fit: StackFit.expand, | ||
| 148 | + children: [ | ||
| 149 | + CachedNetworkImage( | ||
| 150 | + imageUrl: item.coverUrl, | ||
| 151 | + fit: BoxFit.cover, | ||
| 152 | + placeholder: (_, __) => const ColoredBox( | ||
| 153 | + color: Color(0xFFF2F2F2), | ||
| 154 | + child: Center(child: CircularProgressIndicator()), | ||
| 155 | + ), | ||
| 156 | + errorWidget: (_, __, ___) => const ColoredBox( | ||
| 157 | + color: Color(0xFFF2F2F2), | ||
| 158 | + child: Icon(Icons.broken_image_outlined, | ||
| 159 | + color: Color(0xFFAAAAAA)), | ||
| 160 | + ), | ||
| 161 | + ), | ||
| 162 | + const Center( | ||
| 163 | + child: Icon(Icons.play_circle_fill, | ||
| 164 | + size: 42, color: Colors.white), | ||
| 165 | + ), | ||
| 166 | + ], | ||
| 167 | + ), | ||
| 168 | + ), | ||
| 169 | + ), | ||
| 170 | + SizedBox(height: 5.h), | ||
| 171 | + Text( | ||
| 172 | + item.title, | ||
| 173 | + maxLines: 1, | ||
| 174 | + overflow: TextOverflow.ellipsis, | ||
| 175 | + style: TextStyle( | ||
| 176 | + color: const Color(0xFF222222), | ||
| 177 | + fontFamily: null, | ||
| 178 | + fontSize: 17.sp, | ||
| 179 | + fontWeight: FontWeight.w500, | ||
| 180 | + ), | ||
| 181 | + ), | ||
| 182 | + ], | ||
| 183 | + ), | ||
| 184 | + ); | ||
| 185 | + } | ||
| 186 | +} |
lib/pages/video/zone/video_zone_player_page.dart
0 → 100644
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 3 | +import 'package:flutter/material.dart'; | ||
| 4 | +import 'package:video_player/video_player.dart'; | ||
| 5 | +import 'package:wow_english/common/request/dao/video_dao.dart'; | ||
| 6 | + | ||
| 7 | +import 'video_zone_item.dart'; | ||
| 8 | + | ||
| 9 | +class VideoZonePlayerPage extends StatefulWidget { | ||
| 10 | + const VideoZonePlayerPage({ | ||
| 11 | + super.key, | ||
| 12 | + required this.items, | ||
| 13 | + this.initialVideoId, | ||
| 14 | + }) : assert(items.length > 0, '视频播放列表不能为空'); | ||
| 15 | + | ||
| 16 | + final List<VideoZoneItem> items; | ||
| 17 | + final int? initialVideoId; | ||
| 18 | + | ||
| 19 | + @override | ||
| 20 | + State<VideoZonePlayerPage> createState() => _VideoZonePlayerPageState(); | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 24 | + with WidgetsBindingObserver { | ||
| 25 | + VideoPlayerController? _controller; | ||
| 26 | + Timer? _progressTimer; | ||
| 27 | + late int _currentIndex; | ||
| 28 | + double _playbackSpeed = 1; | ||
| 29 | + bool _showControls = true; | ||
| 30 | + bool _switchingVideo = false; | ||
| 31 | + bool _handlingCompletion = false; | ||
| 32 | + int? _lastReportedPosition; | ||
| 33 | + int? _completedReportedVideoId; | ||
| 34 | + Object? _loadError; | ||
| 35 | + | ||
| 36 | + VideoZoneItem get _currentItem => widget.items[_currentIndex]; | ||
| 37 | + | ||
| 38 | + @override | ||
| 39 | + void initState() { | ||
| 40 | + super.initState(); | ||
| 41 | + WidgetsBinding.instance.addObserver(this); | ||
| 42 | + final index = widget.initialVideoId == null | ||
| 43 | + ? 0 | ||
| 44 | + : widget.items.indexWhere((item) => item.id == widget.initialVideoId); | ||
| 45 | + _currentIndex = index < 0 ? 0 : index; | ||
| 46 | + _loadCurrentVideo(); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + Future<void> _loadCurrentVideo() async { | ||
| 50 | + _progressTimer?.cancel(); | ||
| 51 | + final oldController = _controller; | ||
| 52 | + oldController?.removeListener(_onVideoChanged); | ||
| 53 | + final controller = | ||
| 54 | + VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); | ||
| 55 | + _controller = controller; | ||
| 56 | + _loadError = null; | ||
| 57 | + if (mounted) { | ||
| 58 | + setState(() {}); | ||
| 59 | + } | ||
| 60 | + await oldController?.dispose(); | ||
| 61 | + | ||
| 62 | + try { | ||
| 63 | + await controller.initialize(); | ||
| 64 | + if (!mounted || controller != _controller) { | ||
| 65 | + await controller.dispose(); | ||
| 66 | + return; | ||
| 67 | + } | ||
| 68 | + await controller.setLooping(false); | ||
| 69 | + await controller.setPlaybackSpeed(_playbackSpeed); | ||
| 70 | + final savedProgress = | ||
| 71 | + _currentItem.completed ? 0 : _currentItem.progressSeconds; | ||
| 72 | + if (savedProgress > 0 && | ||
| 73 | + savedProgress < controller.value.duration.inSeconds) { | ||
| 74 | + await controller.seekTo(Duration(seconds: savedProgress)); | ||
| 75 | + } | ||
| 76 | + controller.addListener(_onVideoChanged); | ||
| 77 | + await controller.play(); | ||
| 78 | + _lastReportedPosition = null; | ||
| 79 | + _handlingCompletion = false; | ||
| 80 | + _progressTimer = Timer.periodic( | ||
| 81 | + const Duration(seconds: 5), | ||
| 82 | + (_) => _reportCurrentProgress(), | ||
| 83 | + ); | ||
| 84 | + setState(() {}); | ||
| 85 | + } catch (error) { | ||
| 86 | + if (mounted && controller == _controller) { | ||
| 87 | + setState(() => _loadError = error); | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + void _onVideoChanged() { | ||
| 93 | + final controller = _controller; | ||
| 94 | + if (!mounted || controller == null) return; | ||
| 95 | + if (controller.value.isCompleted && | ||
| 96 | + !_switchingVideo && | ||
| 97 | + !_handlingCompletion && | ||
| 98 | + _completedReportedVideoId != _currentItem.id) { | ||
| 99 | + _handlingCompletion = true; | ||
| 100 | + unawaited(_handleVideoCompleted()); | ||
| 101 | + return; | ||
| 102 | + } | ||
| 103 | + setState(() {}); | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + Future<void> _playNext({bool currentCompleted = false}) async { | ||
| 107 | + if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; | ||
| 108 | + _switchingVideo = true; | ||
| 109 | + await _reportCurrentProgress(completed: currentCompleted, force: true); | ||
| 110 | + setState(() => _currentIndex++); | ||
| 111 | + await _loadCurrentVideo(); | ||
| 112 | + _switchingVideo = false; | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + Future<void> _handleVideoCompleted() async { | ||
| 116 | + if (_currentIndex < widget.items.length - 1) { | ||
| 117 | + await _playNext(currentCompleted: true); | ||
| 118 | + } else { | ||
| 119 | + await _reportCurrentProgress(completed: true, force: true); | ||
| 120 | + } | ||
| 121 | + _handlingCompletion = false; | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + Future<void> _togglePlay() async { | ||
| 125 | + final controller = _controller; | ||
| 126 | + if (controller == null || !controller.value.isInitialized) return; | ||
| 127 | + if (controller.value.isCompleted) { | ||
| 128 | + await controller.seekTo(Duration.zero); | ||
| 129 | + _completedReportedVideoId = null; | ||
| 130 | + } | ||
| 131 | + if (controller.value.isPlaying) { | ||
| 132 | + await controller.pause(); | ||
| 133 | + await _reportCurrentProgress(force: true); | ||
| 134 | + } else { | ||
| 135 | + await controller.play(); | ||
| 136 | + } | ||
| 137 | + if (mounted) setState(() {}); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + Future<void> _setSpeed(double speed) async { | ||
| 141 | + _playbackSpeed = speed; | ||
| 142 | + await _controller?.setPlaybackSpeed(speed); | ||
| 143 | + if (mounted) setState(() {}); | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + @override | ||
| 147 | + void dispose() { | ||
| 148 | + WidgetsBinding.instance.removeObserver(this); | ||
| 149 | + _progressTimer?.cancel(); | ||
| 150 | + _controller?.removeListener(_onVideoChanged); | ||
| 151 | + unawaited(_reportCurrentProgress(force: true)); | ||
| 152 | + _controller?.dispose(); | ||
| 153 | + super.dispose(); | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + @override | ||
| 157 | + void didChangeAppLifecycleState(AppLifecycleState state) { | ||
| 158 | + if (state == AppLifecycleState.inactive || | ||
| 159 | + state == AppLifecycleState.paused || | ||
| 160 | + state == AppLifecycleState.detached) { | ||
| 161 | + unawaited(_reportCurrentProgress(force: true)); | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + Future<void> _reportCurrentProgress({ | ||
| 166 | + bool? completed, | ||
| 167 | + bool force = false, | ||
| 168 | + }) async { | ||
| 169 | + final controller = _controller; | ||
| 170 | + if (controller == null || !controller.value.isInitialized) return; | ||
| 171 | + final durationSeconds = controller.value.duration.inSeconds > 0 | ||
| 172 | + ? controller.value.duration.inSeconds | ||
| 173 | + : _currentItem.durationSeconds; | ||
| 174 | + final progressSeconds = controller.value.position.inSeconds | ||
| 175 | + .clamp(0, durationSeconds > 0 ? durationSeconds : 0); | ||
| 176 | + final isCompleted = completed ?? controller.value.isCompleted; | ||
| 177 | + if (!force && _lastReportedPosition == progressSeconds) return; | ||
| 178 | + if (isCompleted && _completedReportedVideoId == _currentItem.id) return; | ||
| 179 | + _lastReportedPosition = progressSeconds; | ||
| 180 | + try { | ||
| 181 | + await VideoDao.savePlayRecord( | ||
| 182 | + videoId: _currentItem.id, | ||
| 183 | + progressSeconds: progressSeconds, | ||
| 184 | + durationSeconds: durationSeconds, | ||
| 185 | + completed: isCompleted, | ||
| 186 | + ); | ||
| 187 | + if (isCompleted) _completedReportedVideoId = _currentItem.id; | ||
| 188 | + } catch (error) { | ||
| 189 | + debugPrint('保存视频播放记录失败: $error'); | ||
| 190 | + } | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + @override | ||
| 194 | + Widget build(BuildContext context) { | ||
| 195 | + final controller = _controller; | ||
| 196 | + return Scaffold( | ||
| 197 | + backgroundColor: Colors.black, | ||
| 198 | + body: GestureDetector( | ||
| 199 | + behavior: HitTestBehavior.opaque, | ||
| 200 | + onTap: () => setState(() => _showControls = !_showControls), | ||
| 201 | + onDoubleTap: _togglePlay, | ||
| 202 | + child: Stack( | ||
| 203 | + fit: StackFit.expand, | ||
| 204 | + children: [ | ||
| 205 | + if (controller?.value.isInitialized == true) | ||
| 206 | + Center( | ||
| 207 | + child: AspectRatio( | ||
| 208 | + aspectRatio: controller!.value.aspectRatio, | ||
| 209 | + child: VideoPlayer(controller), | ||
| 210 | + ), | ||
| 211 | + ) | ||
| 212 | + else if (_loadError != null) | ||
| 213 | + Center( | ||
| 214 | + child: Column( | ||
| 215 | + mainAxisSize: MainAxisSize.min, | ||
| 216 | + children: [ | ||
| 217 | + const Text('视频加载失败', | ||
| 218 | + style: TextStyle(color: Colors.white, fontSize: 18)), | ||
| 219 | + const SizedBox(height: 12), | ||
| 220 | + FilledButton( | ||
| 221 | + onPressed: _loadCurrentVideo, child: const Text('重试')), | ||
| 222 | + ], | ||
| 223 | + ), | ||
| 224 | + ) | ||
| 225 | + else | ||
| 226 | + const Center(child: CircularProgressIndicator()), | ||
| 227 | + if (_showControls) _buildControls(controller), | ||
| 228 | + ], | ||
| 229 | + ), | ||
| 230 | + ), | ||
| 231 | + ); | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + Widget _buildControls(VideoPlayerController? controller) { | ||
| 235 | + final initialized = controller?.value.isInitialized == true; | ||
| 236 | + return ColoredBox( | ||
| 237 | + color: Colors.black38, | ||
| 238 | + child: SafeArea( | ||
| 239 | + child: Column( | ||
| 240 | + children: [ | ||
| 241 | + Row( | ||
| 242 | + children: [ | ||
| 243 | + IconButton( | ||
| 244 | + onPressed: () => Navigator.of(context).pop(), | ||
| 245 | + icon: | ||
| 246 | + const Icon(Icons.arrow_back_ios_new, color: Colors.white), | ||
| 247 | + ), | ||
| 248 | + Expanded( | ||
| 249 | + child: Text( | ||
| 250 | + _currentItem.title, | ||
| 251 | + maxLines: 1, | ||
| 252 | + overflow: TextOverflow.ellipsis, | ||
| 253 | + style: const TextStyle(color: Colors.white, fontSize: 20), | ||
| 254 | + ), | ||
| 255 | + ), | ||
| 256 | + PopupMenuButton<double>( | ||
| 257 | + initialValue: _playbackSpeed, | ||
| 258 | + onSelected: _setSpeed, | ||
| 259 | + itemBuilder: (_) => const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] | ||
| 260 | + .map((speed) => PopupMenuItem( | ||
| 261 | + value: speed, | ||
| 262 | + child: Text('${speed}x'), | ||
| 263 | + )) | ||
| 264 | + .toList(), | ||
| 265 | + child: Padding( | ||
| 266 | + padding: const EdgeInsets.symmetric( | ||
| 267 | + horizontal: 18, vertical: 12), | ||
| 268 | + child: Text('${_playbackSpeed}x', | ||
| 269 | + style: | ||
| 270 | + const TextStyle(color: Colors.white, fontSize: 16)), | ||
| 271 | + ), | ||
| 272 | + ), | ||
| 273 | + ], | ||
| 274 | + ), | ||
| 275 | + const Spacer(), | ||
| 276 | + if (initialized) | ||
| 277 | + IconButton( | ||
| 278 | + onPressed: _togglePlay, | ||
| 279 | + iconSize: 68, | ||
| 280 | + color: Colors.white, | ||
| 281 | + icon: Icon(controller!.value.isPlaying | ||
| 282 | + ? Icons.pause_circle_filled | ||
| 283 | + : Icons.play_circle_fill), | ||
| 284 | + ), | ||
| 285 | + const Spacer(), | ||
| 286 | + if (initialized) | ||
| 287 | + Padding( | ||
| 288 | + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), | ||
| 289 | + child: Row( | ||
| 290 | + children: [ | ||
| 291 | + Expanded( | ||
| 292 | + child: VideoProgressIndicator( | ||
| 293 | + controller!, | ||
| 294 | + allowScrubbing: true, | ||
| 295 | + padding: const EdgeInsets.symmetric(vertical: 12), | ||
| 296 | + colors: const VideoProgressColors( | ||
| 297 | + playedColor: Color(0xFF00B6F1), | ||
| 298 | + bufferedColor: Colors.white54, | ||
| 299 | + backgroundColor: Colors.white24, | ||
| 300 | + ), | ||
| 301 | + ), | ||
| 302 | + ), | ||
| 303 | + const SizedBox(width: 12), | ||
| 304 | + Text( | ||
| 305 | + '${_formatDuration(controller.value.position)} / ' | ||
| 306 | + '${_formatDuration(controller.value.duration)}', | ||
| 307 | + style: const TextStyle(color: Colors.white), | ||
| 308 | + ), | ||
| 309 | + if (_currentIndex < widget.items.length - 1) ...[ | ||
| 310 | + const SizedBox(width: 8), | ||
| 311 | + IconButton( | ||
| 312 | + tooltip: '下一集', | ||
| 313 | + onPressed: _playNext, | ||
| 314 | + icon: const Icon(Icons.skip_next, color: Colors.white), | ||
| 315 | + ), | ||
| 316 | + ], | ||
| 317 | + ], | ||
| 318 | + ), | ||
| 319 | + ), | ||
| 320 | + ], | ||
| 321 | + ), | ||
| 322 | + ), | ||
| 323 | + ); | ||
| 324 | + } | ||
| 325 | + | ||
| 326 | + String _formatDuration(Duration duration) { | ||
| 327 | + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); | ||
| 328 | + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); | ||
| 329 | + if (duration.inHours == 0) return '$minutes:$seconds'; | ||
| 330 | + return '${duration.inHours.toString().padLeft(2, '0')}:$minutes:$seconds'; | ||
| 331 | + } | ||
| 332 | +} |
lib/route/route.dart
| @@ -25,6 +25,9 @@ import 'package:wow_english/pages/user/modify/modify_user_information_page.dart' | @@ -25,6 +25,9 @@ import 'package:wow_english/pages/user/modify/modify_user_information_page.dart' | ||
| 25 | import 'package:wow_english/pages/user/setting/setting_page.dart'; | 25 | import 'package:wow_english/pages/user/setting/setting_page.dart'; |
| 26 | import 'package:wow_english/pages/user/user_page.dart'; | 26 | import 'package:wow_english/pages/user/user_page.dart'; |
| 27 | import 'package:wow_english/pages/video/lookvideo/look_video_page.dart'; | 27 | import 'package:wow_english/pages/video/lookvideo/look_video_page.dart'; |
| 28 | +import 'package:wow_english/pages/video/zone/video_zone_item.dart'; | ||
| 29 | +import 'package:wow_english/pages/video/zone/video_zone_page.dart'; | ||
| 30 | +import 'package:wow_english/pages/video/zone/video_zone_player_page.dart'; | ||
| 28 | 31 | ||
| 29 | import '../models/course_module_entity.dart'; | 32 | import '../models/course_module_entity.dart'; |
| 30 | import '../pages/reading/reading_page.dart'; | 33 | import '../pages/reading/reading_page.dart'; |
| @@ -69,6 +72,12 @@ class AppRouteName { | @@ -69,6 +72,12 @@ class AppRouteName { | ||
| 69 | ///看视频 | 72 | ///看视频 |
| 70 | static const String lookVideo = 'lookVideo'; | 73 | static const String lookVideo = 'lookVideo'; |
| 71 | 74 | ||
| 75 | + ///视频专区 | ||
| 76 | + static const String videoZone = 'videoZone'; | ||
| 77 | + | ||
| 78 | + ///视频专区播放器 | ||
| 79 | + static const String videoZonePlayer = 'videoZonePlayer'; | ||
| 80 | + | ||
| 72 | ///绘本 | 81 | ///绘本 |
| 73 | static const String reading = 'reading'; | 82 | static const String reading = 'reading'; |
| 74 | 83 | ||
| @@ -208,6 +217,17 @@ class AppRouter { | @@ -208,6 +217,17 @@ class AppRouter { | ||
| 208 | courseLessonId: courseLessonId, | 217 | courseLessonId: courseLessonId, |
| 209 | isTopic: isTopic, | 218 | isTopic: isTopic, |
| 210 | )); | 219 | )); |
| 220 | + case AppRouteName.videoZone: | ||
| 221 | + return CupertinoPageRoute(builder: (_) => const VideoZonePage()); | ||
| 222 | + case AppRouteName.videoZonePlayer: | ||
| 223 | + final arguments = settings.arguments as Map; | ||
| 224 | + final items = arguments['items'] as List<VideoZoneItem>; | ||
| 225 | + final initialVideoId = arguments['initialVideoId'] as int?; | ||
| 226 | + return CupertinoPageRoute( | ||
| 227 | + builder: (_) => VideoZonePlayerPage( | ||
| 228 | + items: items, | ||
| 229 | + initialVideoId: initialVideoId, | ||
| 230 | + )); | ||
| 211 | /*case AppRouteName.setPwd: | 231 | /*case AppRouteName.setPwd: |
| 212 | case AppRouteName.setPwd: | 232 | case AppRouteName.setPwd: |
| 213 | phoneNum: phoneNum, | 233 | phoneNum: phoneNum, |