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,10 +5,6 @@ class VideoZoneItem {
5 required this.coverUrl, 5 required this.coverUrl,
6 required this.videoUrl, 6 required this.videoUrl,
7 required this.accessible, 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 factory VideoZoneItem.fromJson(Map<String, dynamic> json) { 10 factory VideoZoneItem.fromJson(Map<String, dynamic> json) {
@@ -23,10 +19,6 @@ class VideoZoneItem { @@ -23,10 +19,6 @@ class VideoZoneItem {
23 coverUrl: json['coverUrl']?.toString() ?? '', 19 coverUrl: json['coverUrl']?.toString() ?? '',
24 videoUrl: json['videoUrl']?.toString() ?? '', 20 videoUrl: json['videoUrl']?.toString() ?? '',
25 accessible: toBool(json['accessible']), 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,10 +27,6 @@ class VideoZoneItem {
35 final String coverUrl; 27 final String coverUrl;
36 final String videoUrl; 28 final String videoUrl;
37 final bool accessible; 29 final bool accessible;
38 - final bool completed;  
39 - final int durationSeconds;  
40 - final int progressSeconds;  
41 - final String lastPlayTime;  
42 30
43 String get title => name; 31 String get title => name;
44 } 32 }
lib/pages/video/zone/video_zone_player_page.dart
1 import 'dart:async'; 1 import 'dart:async';
2 2
  3 +import 'package:chewie/chewie.dart';
  4 +import 'package:flutter/foundation.dart';
3 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
  6 +import 'package:flutter/services.dart';
4 import 'package:video_player/video_player.dart'; 7 import 'package:video_player/video_player.dart';
5 import 'package:wow_english/common/request/dao/video_dao.dart'; 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 import 'video_zone_item.dart'; 11 import 'video_zone_item.dart';
8 12
@@ -23,13 +27,13 @@ class VideoZonePlayerPage extends StatefulWidget { @@ -23,13 +27,13 @@ class VideoZonePlayerPage extends StatefulWidget {
23 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> 27 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
24 with WidgetsBindingObserver { 28 with WidgetsBindingObserver {
25 VideoPlayerController? _controller; 29 VideoPlayerController? _controller;
26 - Timer? _progressTimer; 30 + ChewieController? _chewieController;
  31 + Timer? _cacheTimer;
27 late int _currentIndex; 32 late int _currentIndex;
28 double _playbackSpeed = 1; 33 double _playbackSpeed = 1;
29 - bool _showControls = true;  
30 bool _switchingVideo = false; 34 bool _switchingVideo = false;
31 bool _handlingCompletion = false; 35 bool _handlingCompletion = false;
32 - int? _lastReportedPosition; 36 + bool? _lastIsPlaying;
33 int? _completedReportedVideoId; 37 int? _completedReportedVideoId;
34 Object? _loadError; 38 Object? _loadError;
35 39
@@ -39,6 +43,9 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -39,6 +43,9 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
39 void initState() { 43 void initState() {
40 super.initState(); 44 super.initState();
41 WidgetsBinding.instance.addObserver(this); 45 WidgetsBinding.instance.addObserver(this);
  46 + unawaited(
  47 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
  48 + );
42 final index = widget.initialVideoId == null 49 final index = widget.initialVideoId == null
43 ? 0 50 ? 0
44 : widget.items.indexWhere((item) => item.id == widget.initialVideoId); 51 : widget.items.indexWhere((item) => item.id == widget.initialVideoId);
@@ -47,39 +54,88 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -47,39 +54,88 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
47 } 54 }
48 55
49 Future<void> _loadCurrentVideo() async { 56 Future<void> _loadCurrentVideo() async {
50 - _progressTimer?.cancel(); 57 + final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
  58 + _cacheTimer?.cancel();
51 final oldController = _controller; 59 final oldController = _controller;
  60 + final oldChewieController = _chewieController;
  61 + if (oldController?.value.isInitialized == true) {
  62 + _playbackSpeed = oldController!.value.playbackSpeed;
  63 + }
52 oldController?.removeListener(_onVideoChanged); 64 oldController?.removeListener(_onVideoChanged);
  65 + _lastIsPlaying = null;
53 final controller = 66 final controller =
54 VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); 67 VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl));
55 _controller = controller; 68 _controller = controller;
  69 + _chewieController = null;
56 _loadError = null; 70 _loadError = null;
57 if (mounted) { 71 if (mounted) {
58 setState(() {}); 72 setState(() {});
59 } 73 }
  74 + oldChewieController?.dispose();
60 await oldController?.dispose(); 75 await oldController?.dispose();
61 76
62 try { 77 try {
63 - await controller.initialize(); 78 + await controller.initialize().timeout(const Duration(seconds: 20));
64 if (!mounted || controller != _controller) { 79 if (!mounted || controller != _controller) {
65 await controller.dispose(); 80 await controller.dispose();
66 return; 81 return;
67 } 82 }
68 await controller.setLooping(false); 83 await controller.setLooping(false);
69 await controller.setPlaybackSpeed(_playbackSpeed); 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 if (savedProgress > 0 && 90 if (savedProgress > 0 &&
73 savedProgress < controller.value.duration.inSeconds) { 91 savedProgress < controller.value.duration.inSeconds) {
74 await controller.seekTo(Duration(seconds: savedProgress)); 92 await controller.seekTo(Duration(seconds: savedProgress));
75 } 93 }
  94 + _lastIsPlaying = controller.value.isPlaying;
76 controller.addListener(_onVideoChanged); 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 _handlingCompletion = false; 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 setState(() {}); 140 setState(() {});
85 } catch (error) { 141 } catch (error) {
@@ -92,21 +148,24 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -92,21 +148,24 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
92 void _onVideoChanged() { 148 void _onVideoChanged() {
93 final controller = _controller; 149 final controller = _controller;
94 if (!mounted || controller == null) return; 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 if (controller.value.isCompleted && 156 if (controller.value.isCompleted &&
96 !_switchingVideo && 157 !_switchingVideo &&
97 !_handlingCompletion && 158 !_handlingCompletion &&
98 _completedReportedVideoId != _currentItem.id) { 159 _completedReportedVideoId != _currentItem.id) {
99 _handlingCompletion = true; 160 _handlingCompletion = true;
100 unawaited(_handleVideoCompleted()); 161 unawaited(_handleVideoCompleted());
101 - return;  
102 } 162 }
103 - setState(() {});  
104 } 163 }
105 164
106 Future<void> _playNext({bool currentCompleted = false}) async { 165 Future<void> _playNext({bool currentCompleted = false}) async {
107 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; 166 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
108 _switchingVideo = true; 167 _switchingVideo = true;
109 - await _reportCurrentProgress(completed: currentCompleted, force: true); 168 + await _reportCurrentProgress(completed: currentCompleted);
110 setState(() => _currentIndex++); 169 setState(() => _currentIndex++);
111 await _loadCurrentVideo(); 170 await _loadCurrentVideo();
112 _switchingVideo = false; 171 _switchingVideo = false;
@@ -116,99 +175,127 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -116,99 +175,127 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
116 if (_currentIndex < widget.items.length - 1) { 175 if (_currentIndex < widget.items.length - 1) {
117 await _playNext(currentCompleted: true); 176 await _playNext(currentCompleted: true);
118 } else { 177 } else {
119 - await _reportCurrentProgress(completed: true, force: true); 178 + await _reportCurrentProgress(completed: true);
120 } 179 }
121 _handlingCompletion = false; 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 @override 183 @override
147 void dispose() { 184 void dispose() {
148 WidgetsBinding.instance.removeObserver(this); 185 WidgetsBinding.instance.removeObserver(this);
149 - _progressTimer?.cancel(); 186 + unawaited(
  187 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
  188 + );
  189 + _cacheTimer?.cancel();
150 _controller?.removeListener(_onVideoChanged); 190 _controller?.removeListener(_onVideoChanged);
151 - unawaited(_reportCurrentProgress(force: true)); 191 + unawaited(_reportCurrentProgress());
  192 + _chewieController?.dispose();
152 _controller?.dispose(); 193 _controller?.dispose();
153 super.dispose(); 194 super.dispose();
154 } 195 }
155 196
156 @override 197 @override
157 void didChangeAppLifecycleState(AppLifecycleState state) { 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 state == AppLifecycleState.paused || 204 state == AppLifecycleState.paused ||
160 state == AppLifecycleState.detached) { 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 final controller = _controller; 211 final controller = _controller;
170 if (controller == null || !controller.value.isInitialized) return; 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 final progressSeconds = controller.value.position.inSeconds 215 final progressSeconds = controller.value.position.inSeconds
175 .clamp(0, durationSeconds > 0 ? durationSeconds : 0); 216 .clamp(0, durationSeconds > 0 ? durationSeconds : 0);
176 final isCompleted = completed ?? controller.value.isCompleted; 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 try { 225 try {
181 await VideoDao.savePlayRecord( 226 await VideoDao.savePlayRecord(
182 - videoId: _currentItem.id, 227 + videoId: currentItem.id,
183 progressSeconds: progressSeconds, 228 progressSeconds: progressSeconds,
184 durationSeconds: durationSeconds, 229 durationSeconds: durationSeconds,
185 completed: isCompleted, 230 completed: isCompleted,
186 ); 231 );
187 - if (isCompleted) _completedReportedVideoId = _currentItem.id; 232 + if (isCompleted) _completedReportedVideoId = currentItem.id;
188 } catch (error) { 233 } catch (error) {
189 debugPrint('保存视频播放记录失败: $error'); 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 @override 284 @override
194 Widget build(BuildContext context) { 285 Widget build(BuildContext context) {
195 final controller = _controller; 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 fit: StackFit.expand, 294 fit: StackFit.expand,
204 children: [ 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 else if (_loadError != null) 299 else if (_loadError != null)
213 Center( 300 Center(
214 child: Column( 301 child: Column(
@@ -224,109 +311,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -224,109 +311,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
224 ) 311 )
225 else 312 else
226 const Center(child: CircularProgressIndicator()), 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 child: Row( 318 child: Row(
290 children: [ 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 Expanded( 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,6 +233,14 @@ packages:
233 url: "https://pub.flutter-io.cn" 233 url: "https://pub.flutter-io.cn"
234 source: hosted 234 source: hosted
235 version: "2.0.3" 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 chivox_aiengine: 244 chivox_aiengine:
237 dependency: "direct main" 245 dependency: "direct main"
238 description: 246 description:
@@ -1301,7 +1309,7 @@ packages: @@ -1301,7 +1309,7 @@ packages:
1301 source: hosted 1309 source: hosted
1302 version: "7.0.0" 1310 version: "7.0.0"
1303 sqflite: 1311 sqflite:
1304 - dependency: transitive 1312 + dependency: "direct main"
1305 description: 1313 description:
1306 name: sqflite 1314 name: sqflite
1307 sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d 1315 sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d
@@ -1540,6 +1548,22 @@ packages: @@ -1540,6 +1548,22 @@ packages:
1540 url: "https://pub.flutter-io.cn" 1548 url: "https://pub.flutter-io.cn"
1541 source: hosted 1549 source: hosted
1542 version: "14.3.0" 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 watcher: 1567 watcher:
1544 dependency: transitive 1568 dependency: transitive
1545 description: 1569 description:
pubspec.yaml
@@ -56,6 +56,8 @@ dependencies: @@ -56,6 +56,8 @@ dependencies:
56 pull_to_refresh: ^2.0.0 56 pull_to_refresh: ^2.0.0
57 # 数据持久化 https://pub.dev/packages/shared_preferences 57 # 数据持久化 https://pub.dev/packages/shared_preferences
58 shared_preferences: ^2.1.2 58 shared_preferences: ^2.1.2
  59 + # SQLite 数据库 https://pub.dev/packages/sqflite
  60 + sqflite: ^2.3.3+1
59 #字体/尺寸适配 https://pub.dev/packages/flutter_screenutil 61 #字体/尺寸适配 https://pub.dev/packages/flutter_screenutil
60 flutter_screenutil: ^5.8.4 62 flutter_screenutil: ^5.8.4
61 # 显示网络等待插件 https://pub.flutter-io.cn/packages/flutter_easyloading 63 # 显示网络等待插件 https://pub.flutter-io.cn/packages/flutter_easyloading
@@ -94,6 +96,8 @@ dependencies: @@ -94,6 +96,8 @@ dependencies:
94 extended_text: ^11.0.1 96 extended_text: ^11.0.1
95 # 视频播放 https://pub.dev/packages/video_player 97 # 视频播放 https://pub.dev/packages/video_player
96 video_player: 2.9.2 98 video_player: 2.9.2
  99 + # 视频播放器控制层 https://pub.dev/packages/chewie
  100 + chewie: 1.7.5
97 # 驰声语音评测 SDK(官方离线包裁剪为当前支持架构) 101 # 驰声语音评测 SDK(官方离线包裁剪为当前支持架构)
98 chivox_aiengine: 102 chivox_aiengine:
99 path: packages/chivox_aiengine 103 path: packages/chivox_aiengine