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,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<VideoZonePlayerPage> | @@ -39,6 +43,9 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -47,39 +54,88 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -92,21 +148,24 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -116,99 +175,127 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -224,109 +311,52 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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 |