Commit 383462ef58cee4f3dbd200eb45201f526067d045

Authored by 吴启风
1 parent 9d058cb4

feat: 新增视频专区列表与播放功能

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 &#39;package:wow_english/pages/user/modify/modify_user_information_page.dart&#39; @@ -25,6 +25,9 @@ import &#39;package:wow_english/pages/user/modify/modify_user_information_page.dart&#39;
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,