Commit 7e98b7321a67a577687ec5b7c08da01a5f6f9539
1 parent
e9e6c0ca
feat: 完善视频播放与本地进度缓存
Showing
5 changed files
with
355 additions
and
171 deletions
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<VideoZonePlayerPage> |
| 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<VideoZonePlayerPage> |
| 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<VideoZonePlayerPage> |
| 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<VideoZonePlayerPage> |
| 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<VideoZonePlayerPage> |
| 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 | ... | ... |