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 | 110 | /// 用户反馈 |
| 111 | 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 | 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 | 149 | Expanded( |
| 129 | 150 | child: BlocBuilder<UserBloc, UserState>( |
| 130 | 151 | builder: (context, userState) { |
| ... | ... | @@ -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 | 239 | if (UserUtil.isLogined()) { |
| 214 | 240 | if (AppConfigHelper.shouldHidePay()) { |
| 215 | 241 | onAllowed(); |
| ... | ... | @@ -217,7 +243,7 @@ class _HomePageView extends StatelessWidget { |
| 217 | 243 | if (UserUtil.hasPermission()) { |
| 218 | 244 | onAllowed(); |
| 219 | 245 | } else { |
| 220 | - showTwoActionDialog('提示', '忽略', '去续费', '您的课程已到期,请快快续费继续学习吧!', | |
| 246 | + showTwoActionDialog('提示', '忽略', deniedActionText, deniedMessage, | |
| 221 | 247 | leftTap: () { |
| 222 | 248 | popPage(); |
| 223 | 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 | 25 | import 'package:wow_english/pages/user/setting/setting_page.dart'; |
| 26 | 26 | import 'package:wow_english/pages/user/user_page.dart'; |
| 27 | 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 | 32 | import '../models/course_module_entity.dart'; |
| 30 | 33 | import '../pages/reading/reading_page.dart'; |
| ... | ... | @@ -69,6 +72,12 @@ class AppRouteName { |
| 69 | 72 | ///看视频 |
| 70 | 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 | 82 | static const String reading = 'reading'; |
| 74 | 83 | |
| ... | ... | @@ -208,6 +217,17 @@ class AppRouter { |
| 208 | 217 | courseLessonId: courseLessonId, |
| 209 | 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 | 231 | /*case AppRouteName.setPwd: |
| 212 | 232 | case AppRouteName.setPwd: |
| 213 | 233 | phoneNum: phoneNum, | ... | ... |