Commit 7e98b7321a67a577687ec5b7c08da01a5f6f9539

Authored by 吴启风
1 parent e9e6c0ca

feat: 完善视频播放与本地进度缓存

lib/common/utils/video_progress_cache.dart 0 → 100644
  1 +import 'package:sqflite/sqflite.dart';
  2 +import 'package:wow_english/common/core/user_util.dart';
  3 +
  4 +class VideoProgressCacheEntry {
  5 + const VideoProgressCacheEntry({
  6 + required this.progressSeconds,
  7 + required this.durationSeconds,
  8 + });
  9 +
  10 + final int progressSeconds;
  11 + final int durationSeconds;
  12 +}
  13 +
  14 +/// 使用 SQLite 按用户保存视频播放进度。
  15 +class VideoProgressCache {
  16 + static const _databaseName = 'video_progress.db';
  17 + static const _databaseVersion = 1;
  18 + static const _tableName = 'video_progress';
  19 + static const _maxRecordCount = 100;
  20 +
  21 + static Database? _database;
  22 + static Future<Database>? _openingDatabase;
  23 + static Future<void> _writeQueue = Future<void>.value();
  24 +
  25 + static Future<VideoProgressCacheEntry?> get(int videoId) async {
  26 + final userId = _currentUserId;
  27 + if (userId == null) return null;
  28 + await _writeQueue;
  29 + final database = await _getDatabase();
  30 + final rows = await database.query(
  31 + _tableName,
  32 + columns: const ['progress_seconds', 'duration_seconds'],
  33 + where: 'user_id = ? AND video_id = ?',
  34 + whereArgs: [userId, videoId],
  35 + limit: 1,
  36 + );
  37 + if (rows.isEmpty) return null;
  38 + return VideoProgressCacheEntry(
  39 + progressSeconds: _toInt(rows.first['progress_seconds']),
  40 + durationSeconds: _toInt(rows.first['duration_seconds']),
  41 + );
  42 + }
  43 +
  44 + static Future<void> save({
  45 + required int videoId,
  46 + required int progressSeconds,
  47 + required int durationSeconds,
  48 + }) async {
  49 + final userId = _currentUserId;
  50 + if (userId == null) return;
  51 + await _enqueueWrite(() async {
  52 + final database = await _getDatabase();
  53 + await database.transaction((transaction) async {
  54 + await transaction.insert(
  55 + _tableName,
  56 + {
  57 + 'user_id': userId,
  58 + 'video_id': videoId,
  59 + 'progress_seconds': progressSeconds,
  60 + 'duration_seconds': durationSeconds,
  61 + 'updated_at': DateTime.now().millisecondsSinceEpoch,
  62 + },
  63 + conflictAlgorithm: ConflictAlgorithm.replace,
  64 + );
  65 + await transaction.rawDelete(
  66 + '''
  67 + DELETE FROM $_tableName
  68 + WHERE user_id = ? AND video_id NOT IN (
  69 + SELECT video_id FROM $_tableName
  70 + WHERE user_id = ?
  71 + ORDER BY updated_at DESC
  72 + LIMIT ?
  73 + )
  74 + ''',
  75 + [userId, userId, _maxRecordCount],
  76 + );
  77 + });
  78 + });
  79 + }
  80 +
  81 + static Future<void> remove(int videoId) async {
  82 + final userId = _currentUserId;
  83 + if (userId == null) return;
  84 + await _enqueueWrite(() async {
  85 + final database = await _getDatabase();
  86 + await database.delete(
  87 + _tableName,
  88 + where: 'user_id = ? AND video_id = ?',
  89 + whereArgs: [userId, videoId],
  90 + );
  91 + });
  92 + }
  93 +
  94 + static int? get _currentUserId => UserUtil.getUser()?.id;
  95 +
  96 + static Future<void> _enqueueWrite(Future<void> Function() operation) {
  97 + final future = _writeQueue.then((_) => operation());
  98 + _writeQueue = future.then<void>((_) {}, onError: (_, __) {});
  99 + return future;
  100 + }
  101 +
  102 + static Future<Database> _getDatabase() async {
  103 + final database = _database;
  104 + if (database != null && database.isOpen) return database;
  105 + final openingDatabase = _openingDatabase;
  106 + if (openingDatabase != null) return openingDatabase;
  107 + final future = _openDatabase();
  108 + _openingDatabase = future;
  109 + try {
  110 + final result = await future;
  111 + _database = result;
  112 + return result;
  113 + } finally {
  114 + _openingDatabase = null;
  115 + }
  116 + }
  117 +
  118 + static Future<Database> _openDatabase() async {
  119 + final databasesPath = await getDatabasesPath();
  120 + return openDatabase(
  121 + '$databasesPath/$_databaseName',
  122 + version: _databaseVersion,
  123 + onCreate: (database, _) => database.execute('''
  124 + CREATE TABLE $_tableName (
  125 + user_id INTEGER NOT NULL,
  126 + video_id INTEGER NOT NULL,
  127 + progress_seconds INTEGER NOT NULL,
  128 + duration_seconds INTEGER NOT NULL,
  129 + updated_at INTEGER NOT NULL,
  130 + PRIMARY KEY (user_id, video_id)
  131 + )
  132 + '''),
  133 + );
  134 + }
  135 +
  136 + static int _toInt(Object? value) =>
  137 + value is num ? value.toInt() : int.tryParse('$value') ?? 0;
  138 +}
... ...
lib/pages/video/zone/video_zone_item.dart
... ... @@ -5,10 +5,6 @@ class VideoZoneItem {
5 5 required this.coverUrl,
6 6 required this.videoUrl,
7 7 required this.accessible,
8   - required this.completed,
9   - required this.durationSeconds,
10   - required this.progressSeconds,
11   - required this.lastPlayTime,
12 8 });
13 9  
14 10 factory VideoZoneItem.fromJson(Map<String, dynamic> json) {
... ... @@ -23,10 +19,6 @@ class VideoZoneItem {
23 19 coverUrl: json['coverUrl']?.toString() ?? '',
24 20 videoUrl: json['videoUrl']?.toString() ?? '',
25 21 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 22 );
31 23 }
32 24  
... ... @@ -35,10 +27,6 @@ class VideoZoneItem {
35 27 final String coverUrl;
36 28 final String videoUrl;
37 29 final bool accessible;
38   - final bool completed;
39   - final int durationSeconds;
40   - final int progressSeconds;
41   - final String lastPlayTime;
42 30  
43 31 String get title => name;
44 32 }
... ...
lib/pages/video/zone/video_zone_player_page.dart
1 1 import 'dart:async';
2 2  
  3 +import 'package:chewie/chewie.dart';
  4 +import 'package:flutter/foundation.dart';
3 5 import 'package:flutter/material.dart';
  6 +import 'package:flutter/services.dart';
4 7 import 'package:video_player/video_player.dart';
5 8 import 'package:wow_english/common/request/dao/video_dao.dart';
  9 +import 'package:wow_english/common/utils/video_progress_cache.dart';
6 10  
7 11 import 'video_zone_item.dart';
8 12  
... ... @@ -23,13 +27,13 @@ class VideoZonePlayerPage extends StatefulWidget {
23 27 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
24 28 with WidgetsBindingObserver {
25 29 VideoPlayerController? _controller;
26   - Timer? _progressTimer;
  30 + ChewieController? _chewieController;
  31 + Timer? _cacheTimer;
27 32 late int _currentIndex;
28 33 double _playbackSpeed = 1;
29   - bool _showControls = true;
30 34 bool _switchingVideo = false;
31 35 bool _handlingCompletion = false;
32   - int? _lastReportedPosition;
  36 + bool? _lastIsPlaying;
33 37 int? _completedReportedVideoId;
34 38 Object? _loadError;
35 39  
... ... @@ -39,6 +43,9 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
39 43 void initState() {
40 44 super.initState();
41 45 WidgetsBinding.instance.addObserver(this);
  46 + unawaited(
  47 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
  48 + );
42 49 final index = widget.initialVideoId == null
43 50 ? 0
44 51 : widget.items.indexWhere((item) => item.id == widget.initialVideoId);
... ... @@ -47,39 +54,88 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
47 54 }
48 55  
49 56 Future<void> _loadCurrentVideo() async {
50   - _progressTimer?.cancel();
  57 + final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
  58 + _cacheTimer?.cancel();
51 59 final oldController = _controller;
  60 + final oldChewieController = _chewieController;
  61 + if (oldController?.value.isInitialized == true) {
  62 + _playbackSpeed = oldController!.value.playbackSpeed;
  63 + }
52 64 oldController?.removeListener(_onVideoChanged);
  65 + _lastIsPlaying = null;
53 66 final controller =
54 67 VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl));
55 68 _controller = controller;
  69 + _chewieController = null;
56 70 _loadError = null;
57 71 if (mounted) {
58 72 setState(() {});
59 73 }
  74 + oldChewieController?.dispose();
60 75 await oldController?.dispose();
61 76  
62 77 try {
63   - await controller.initialize();
  78 + await controller.initialize().timeout(const Duration(seconds: 20));
64 79 if (!mounted || controller != _controller) {
65 80 await controller.dispose();
66 81 return;
67 82 }
68 83 await controller.setLooping(false);
69 84 await controller.setPlaybackSpeed(_playbackSpeed);
70   - final savedProgress =
71   - _currentItem.completed ? 0 : _currentItem.progressSeconds;
  85 + final savedProgress = await _resumeProgressFor(_currentItem.id);
  86 + if (!mounted || controller != _controller) {
  87 + await controller.dispose();
  88 + return;
  89 + }
72 90 if (savedProgress > 0 &&
73 91 savedProgress < controller.value.duration.inSeconds) {
74 92 await controller.seekTo(Duration(seconds: savedProgress));
75 93 }
  94 + _lastIsPlaying = controller.value.isPlaying;
76 95 controller.addListener(_onVideoChanged);
77   - await controller.play();
78   - _lastReportedPosition = null;
  96 + _chewieController = ChewieController(
  97 + videoPlayerController: controller,
  98 + autoPlay: true,
  99 + looping: false,
  100 + allowFullScreen: false,
  101 + allowPlaybackSpeedChanging: true,
  102 + draggableProgressBar: true,
  103 + allowedScreenSleep: false,
  104 + playbackSpeeds: const [0.5, 0.75, 1, 1.25, 1.5, 2],
  105 + customControls:
  106 + useCupertinoControls ? null : const _VideoMaterialControls(),
  107 + optionsTranslation: OptionsTranslation(
  108 + playbackSpeedButtonText: '播放速度',
  109 + cancelButtonText: '取消',
  110 + ),
  111 + materialProgressColors: ChewieProgressColors(
  112 + playedColor: const Color(0xFF00B6F1),
  113 + handleColor: const Color(0xFF00B6F1),
  114 + bufferedColor: Colors.white54,
  115 + backgroundColor: Colors.white24,
  116 + ),
  117 + cupertinoProgressColors: ChewieProgressColors(
  118 + playedColor: const Color(0xFF00B6F1),
  119 + handleColor: const Color(0xFF00B6F1),
  120 + bufferedColor: Colors.white54,
  121 + backgroundColor: Colors.white24,
  122 + ),
  123 + placeholder: const ColoredBox(color: Colors.black),
  124 + errorBuilder: (_, __) => const Center(
  125 + child: Text(
  126 + '视频播放失败',
  127 + style: TextStyle(color: Colors.white, fontSize: 18),
  128 + ),
  129 + ),
  130 + );
79 131 _handlingCompletion = false;
80   - _progressTimer = Timer.periodic(
81   - const Duration(seconds: 5),
82   - (_) => _reportCurrentProgress(),
  132 + _cacheTimer = Timer.periodic(
  133 + const Duration(seconds: 30),
  134 + (_) {
  135 + if (_controller?.value.isPlaying == true) {
  136 + unawaited(_cacheCurrentProgress());
  137 + }
  138 + },
83 139 );
84 140 setState(() {});
85 141 } catch (error) {
... ... @@ -92,21 +148,24 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
92 148 void _onVideoChanged() {
93 149 final controller = _controller;
94 150 if (!mounted || controller == null) return;
  151 + final isPlaying = controller.value.isPlaying;
  152 + if (_lastIsPlaying != null && _lastIsPlaying != isPlaying) {
  153 + unawaited(_cacheCurrentProgress());
  154 + }
  155 + _lastIsPlaying = isPlaying;
95 156 if (controller.value.isCompleted &&
96 157 !_switchingVideo &&
97 158 !_handlingCompletion &&
98 159 _completedReportedVideoId != _currentItem.id) {
99 160 _handlingCompletion = true;
100 161 unawaited(_handleVideoCompleted());
101   - return;
102 162 }
103   - setState(() {});
104 163 }
105 164  
106 165 Future<void> _playNext({bool currentCompleted = false}) async {
107 166 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
108 167 _switchingVideo = true;
109   - await _reportCurrentProgress(completed: currentCompleted, force: true);
  168 + await _reportCurrentProgress(completed: currentCompleted);
110 169 setState(() => _currentIndex++);
111 170 await _loadCurrentVideo();
112 171 _switchingVideo = false;
... ... @@ -116,99 +175,127 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
116 175 if (_currentIndex < widget.items.length - 1) {
117 176 await _playNext(currentCompleted: true);
118 177 } else {
119   - await _reportCurrentProgress(completed: true, force: true);
  178 + await _reportCurrentProgress(completed: true);
120 179 }
121 180 _handlingCompletion = false;
122 181 }
123 182  
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 183 @override
147 184 void dispose() {
148 185 WidgetsBinding.instance.removeObserver(this);
149   - _progressTimer?.cancel();
  186 + unawaited(
  187 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
  188 + );
  189 + _cacheTimer?.cancel();
150 190 _controller?.removeListener(_onVideoChanged);
151   - unawaited(_reportCurrentProgress(force: true));
  191 + unawaited(_reportCurrentProgress());
  192 + _chewieController?.dispose();
152 193 _controller?.dispose();
153 194 super.dispose();
154 195 }
155 196  
156 197 @override
157 198 void didChangeAppLifecycleState(AppLifecycleState state) {
158   - if (state == AppLifecycleState.inactive ||
  199 + if (state == AppLifecycleState.resumed) {
  200 + unawaited(
  201 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
  202 + );
  203 + } else if (state == AppLifecycleState.inactive ||
159 204 state == AppLifecycleState.paused ||
160 205 state == AppLifecycleState.detached) {
161   - unawaited(_reportCurrentProgress(force: true));
  206 + unawaited(_cacheCurrentProgress());
162 207 }
163 208 }
164 209  
165   - Future<void> _reportCurrentProgress({
166   - bool? completed,
167   - bool force = false,
168   - }) async {
  210 + Future<void> _reportCurrentProgress({bool? completed}) async {
169 211 final controller = _controller;
170 212 if (controller == null || !controller.value.isInitialized) return;
171   - final durationSeconds = controller.value.duration.inSeconds > 0
172   - ? controller.value.duration.inSeconds
173   - : _currentItem.durationSeconds;
  213 + final currentItem = _currentItem;
  214 + final durationSeconds = controller.value.duration.inSeconds;
174 215 final progressSeconds = controller.value.position.inSeconds
175 216 .clamp(0, durationSeconds > 0 ? durationSeconds : 0);
176 217 final isCompleted = completed ?? controller.value.isCompleted;
177   - if (!force && _lastReportedPosition == progressSeconds) return;
178   - if (isCompleted && _completedReportedVideoId == _currentItem.id) return;
179   - _lastReportedPosition = progressSeconds;
  218 + await _persistCachedProgress(
  219 + videoId: currentItem.id,
  220 + progressSeconds: progressSeconds,
  221 + durationSeconds: durationSeconds,
  222 + completed: isCompleted,
  223 + );
  224 + if (isCompleted && _completedReportedVideoId == currentItem.id) return;
180 225 try {
181 226 await VideoDao.savePlayRecord(
182   - videoId: _currentItem.id,
  227 + videoId: currentItem.id,
183 228 progressSeconds: progressSeconds,
184 229 durationSeconds: durationSeconds,
185 230 completed: isCompleted,
186 231 );
187   - if (isCompleted) _completedReportedVideoId = _currentItem.id;
  232 + if (isCompleted) _completedReportedVideoId = currentItem.id;
188 233 } catch (error) {
189 234 debugPrint('保存视频播放记录失败: $error');
190 235 }
191 236 }
192 237  
  238 + Future<int> _resumeProgressFor(int videoId) async {
  239 + try {
  240 + final cached = await VideoProgressCache.get(videoId);
  241 + if (cached != null) return cached.progressSeconds;
  242 + } catch (error) {
  243 + debugPrint('读取本地视频播放进度失败: $error');
  244 + }
  245 + return 0;
  246 + }
  247 +
  248 + Future<void> _cacheCurrentProgress() async {
  249 + final controller = _controller;
  250 + if (controller == null || !controller.value.isInitialized) return;
  251 + final currentItem = _currentItem;
  252 + final durationSeconds = controller.value.duration.inSeconds;
  253 + final progressSeconds = controller.value.position.inSeconds
  254 + .clamp(0, durationSeconds > 0 ? durationSeconds : 0);
  255 + await _persistCachedProgress(
  256 + videoId: currentItem.id,
  257 + progressSeconds: progressSeconds,
  258 + durationSeconds: durationSeconds,
  259 + completed: controller.value.isCompleted,
  260 + );
  261 + }
  262 +
  263 + Future<void> _persistCachedProgress({
  264 + required int videoId,
  265 + required int progressSeconds,
  266 + required int durationSeconds,
  267 + required bool completed,
  268 + }) async {
  269 + try {
  270 + if (completed) {
  271 + await VideoProgressCache.remove(videoId);
  272 + } else {
  273 + await VideoProgressCache.save(
  274 + videoId: videoId,
  275 + progressSeconds: progressSeconds,
  276 + durationSeconds: durationSeconds,
  277 + );
  278 + }
  279 + } catch (error) {
  280 + debugPrint('保存本地视频播放进度失败: $error');
  281 + }
  282 + }
  283 +
193 284 @override
194 285 Widget build(BuildContext context) {
195 286 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(
  287 + final chewieController = _chewieController;
  288 + return MediaQuery.removePadding(
  289 + context: context,
  290 + removeTop: true,
  291 + child: Scaffold(
  292 + backgroundColor: Colors.black,
  293 + body: Stack(
203 294 fit: StackFit.expand,
204 295 children: [
205   - if (controller?.value.isInitialized == true)
206   - Center(
207   - child: AspectRatio(
208   - aspectRatio: controller!.value.aspectRatio,
209   - child: VideoPlayer(controller),
210   - ),
211   - )
  296 + if (controller?.value.isInitialized == true &&
  297 + chewieController != null)
  298 + Chewie(controller: chewieController)
212 299 else if (_loadError != null)
213 300 Center(
214 301 child: Column(
... ... @@ -224,109 +311,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
224 311 )
225 312 else
226 313 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),
  314 + SafeArea(
  315 + top: false,
  316 + child: Align(
  317 + alignment: Alignment.topLeft,
289 318 child: Row(
290 319 children: [
  320 + IconButton(
  321 + onPressed: () => Navigator.of(context).pop(),
  322 + icon: const Icon(Icons.arrow_back_ios_new,
  323 + color: Colors.white),
  324 + ),
291 325 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   - ),
  326 + child: Text(
  327 + _currentItem.title,
  328 + maxLines: 1,
  329 + overflow: TextOverflow.ellipsis,
  330 + style:
  331 + const TextStyle(color: Colors.white, fontSize: 20),
301 332 ),
302 333 ),
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 334 ],
318 335 ),
319 336 ),
  337 + ),
320 338 ],
321 339 ),
322 340 ),
323 341 );
324 342 }
  343 +}
325 344  
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';
  345 +class _VideoMaterialControls extends StatelessWidget {
  346 + const _VideoMaterialControls();
  347 +
  348 + @override
  349 + Widget build(BuildContext context) {
  350 + return Theme(
  351 + data: Theme.of(context).copyWith(
  352 + iconButtonTheme: IconButtonThemeData(
  353 + style: IconButton.styleFrom(
  354 + minimumSize: const Size.square(72),
  355 + padding: const EdgeInsets.all(20),
  356 + ),
  357 + ),
  358 + ),
  359 + child: const MaterialControls(),
  360 + );
331 361 }
332 362 }
... ...
pubspec.lock
... ... @@ -233,6 +233,14 @@ packages:
233 233 url: "https://pub.flutter-io.cn"
234 234 source: hosted
235 235 version: "2.0.3"
  236 + chewie:
  237 + dependency: "direct main"
  238 + description:
  239 + name: chewie
  240 + sha256: "8bc4ac4cf3f316e50a25958c0f5eb9bb12cf7e8308bb1d74a43b230da2cfc144"
  241 + url: "https://pub.flutter-io.cn"
  242 + source: hosted
  243 + version: "1.7.5"
236 244 chivox_aiengine:
237 245 dependency: "direct main"
238 246 description:
... ... @@ -1301,7 +1309,7 @@ packages:
1301 1309 source: hosted
1302 1310 version: "7.0.0"
1303 1311 sqflite:
1304   - dependency: transitive
  1312 + dependency: "direct main"
1305 1313 description:
1306 1314 name: sqflite
1307 1315 sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d
... ... @@ -1540,6 +1548,22 @@ packages:
1540 1548 url: "https://pub.flutter-io.cn"
1541 1549 source: hosted
1542 1550 version: "14.3.0"
  1551 + wakelock_plus:
  1552 + dependency: transitive
  1553 + description:
  1554 + name: wakelock_plus
  1555 + sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d
  1556 + url: "https://pub.flutter-io.cn"
  1557 + source: hosted
  1558 + version: "1.1.4"
  1559 + wakelock_plus_platform_interface:
  1560 + dependency: transitive
  1561 + description:
  1562 + name: wakelock_plus_platform_interface
  1563 + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
  1564 + url: "https://pub.flutter-io.cn"
  1565 + source: hosted
  1566 + version: "1.3.0"
1543 1567 watcher:
1544 1568 dependency: transitive
1545 1569 description:
... ...
pubspec.yaml
... ... @@ -56,6 +56,8 @@ dependencies:
56 56 pull_to_refresh: ^2.0.0
57 57 # 数据持久化 https://pub.dev/packages/shared_preferences
58 58 shared_preferences: ^2.1.2
  59 + # SQLite 数据库 https://pub.dev/packages/sqflite
  60 + sqflite: ^2.3.3+1
59 61 #字体/尺寸适配 https://pub.dev/packages/flutter_screenutil
60 62 flutter_screenutil: ^5.8.4
61 63 # 显示网络等待插件 https://pub.flutter-io.cn/packages/flutter_easyloading
... ... @@ -94,6 +96,8 @@ dependencies:
94 96 extended_text: ^11.0.1
95 97 # 视频播放 https://pub.dev/packages/video_player
96 98 video_player: 2.9.2
  99 + # 视频播放器控制层 https://pub.dev/packages/chewie
  100 + chewie: 1.7.5
97 101 # 驰声语音评测 SDK(官方离线包裁剪为当前支持架构)
98 102 chivox_aiengine:
99 103 path: packages/chivox_aiengine
... ...