From 7e98b7321a67a577687ec5b7c08da01a5f6f9539 Mon Sep 17 00:00:00 2001 From: wuqifeng <540416539@qq.com> Date: Sun, 13 Sep 2026 18:47:06 +0800 Subject: [PATCH] feat: 完善视频播放与本地进度缓存 --- lib/common/utils/video_progress_cache.dart | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/pages/video/zone/video_zone_item.dart | 12 ------------ lib/pages/video/zone/video_zone_player_page.dart | 346 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------------------------------------- pubspec.lock | 26 +++++++++++++++++++++++++- pubspec.yaml | 4 ++++ 5 files changed, 355 insertions(+), 171 deletions(-) create mode 100644 lib/common/utils/video_progress_cache.dart diff --git a/lib/common/utils/video_progress_cache.dart b/lib/common/utils/video_progress_cache.dart new file mode 100644 index 0000000..a01b3cf --- /dev/null +++ b/lib/common/utils/video_progress_cache.dart @@ -0,0 +1,138 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:wow_english/common/core/user_util.dart'; + +class VideoProgressCacheEntry { + const VideoProgressCacheEntry({ + required this.progressSeconds, + required this.durationSeconds, + }); + + final int progressSeconds; + final int durationSeconds; +} + +/// 使用 SQLite 按用户保存视频播放进度。 +class VideoProgressCache { + static const _databaseName = 'video_progress.db'; + static const _databaseVersion = 1; + static const _tableName = 'video_progress'; + static const _maxRecordCount = 100; + + static Database? _database; + static Future? _openingDatabase; + static Future _writeQueue = Future.value(); + + static Future get(int videoId) async { + final userId = _currentUserId; + if (userId == null) return null; + await _writeQueue; + final database = await _getDatabase(); + final rows = await database.query( + _tableName, + columns: const ['progress_seconds', 'duration_seconds'], + where: 'user_id = ? AND video_id = ?', + whereArgs: [userId, videoId], + limit: 1, + ); + if (rows.isEmpty) return null; + return VideoProgressCacheEntry( + progressSeconds: _toInt(rows.first['progress_seconds']), + durationSeconds: _toInt(rows.first['duration_seconds']), + ); + } + + static Future save({ + required int videoId, + required int progressSeconds, + required int durationSeconds, + }) async { + final userId = _currentUserId; + if (userId == null) return; + await _enqueueWrite(() async { + final database = await _getDatabase(); + await database.transaction((transaction) async { + await transaction.insert( + _tableName, + { + 'user_id': userId, + 'video_id': videoId, + 'progress_seconds': progressSeconds, + 'duration_seconds': durationSeconds, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await transaction.rawDelete( + ''' + DELETE FROM $_tableName + WHERE user_id = ? AND video_id NOT IN ( + SELECT video_id FROM $_tableName + WHERE user_id = ? + ORDER BY updated_at DESC + LIMIT ? + ) + ''', + [userId, userId, _maxRecordCount], + ); + }); + }); + } + + static Future remove(int videoId) async { + final userId = _currentUserId; + if (userId == null) return; + await _enqueueWrite(() async { + final database = await _getDatabase(); + await database.delete( + _tableName, + where: 'user_id = ? AND video_id = ?', + whereArgs: [userId, videoId], + ); + }); + } + + static int? get _currentUserId => UserUtil.getUser()?.id; + + static Future _enqueueWrite(Future Function() operation) { + final future = _writeQueue.then((_) => operation()); + _writeQueue = future.then((_) {}, onError: (_, __) {}); + return future; + } + + static Future _getDatabase() async { + final database = _database; + if (database != null && database.isOpen) return database; + final openingDatabase = _openingDatabase; + if (openingDatabase != null) return openingDatabase; + final future = _openDatabase(); + _openingDatabase = future; + try { + final result = await future; + _database = result; + return result; + } finally { + _openingDatabase = null; + } + } + + static Future _openDatabase() async { + final databasesPath = await getDatabasesPath(); + return openDatabase( + '$databasesPath/$_databaseName', + version: _databaseVersion, + onCreate: (database, _) => database.execute(''' + CREATE TABLE $_tableName ( + user_id INTEGER NOT NULL, + video_id INTEGER NOT NULL, + progress_seconds INTEGER NOT NULL, + duration_seconds INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, video_id) + ) + '''), + ); + } + + static int _toInt(Object? value) => + value is num ? value.toInt() : int.tryParse('$value') ?? 0; +} diff --git a/lib/pages/video/zone/video_zone_item.dart b/lib/pages/video/zone/video_zone_item.dart index 2e793cd..b89d075 100644 --- a/lib/pages/video/zone/video_zone_item.dart +++ b/lib/pages/video/zone/video_zone_item.dart @@ -5,10 +5,6 @@ class VideoZoneItem { required this.coverUrl, required this.videoUrl, required this.accessible, - required this.completed, - required this.durationSeconds, - required this.progressSeconds, - required this.lastPlayTime, }); factory VideoZoneItem.fromJson(Map json) { @@ -23,10 +19,6 @@ class VideoZoneItem { coverUrl: json['coverUrl']?.toString() ?? '', videoUrl: json['videoUrl']?.toString() ?? '', accessible: toBool(json['accessible']), - completed: toBool(json['completed']), - durationSeconds: toInt(json['durationSeconds']), - progressSeconds: toInt(json['progressSeconds']), - lastPlayTime: json['lastPlayTime']?.toString() ?? '', ); } @@ -35,10 +27,6 @@ class VideoZoneItem { final String coverUrl; final String videoUrl; final bool accessible; - final bool completed; - final int durationSeconds; - final int progressSeconds; - final String lastPlayTime; String get title => name; } diff --git a/lib/pages/video/zone/video_zone_player_page.dart b/lib/pages/video/zone/video_zone_player_page.dart index cfdd940..97ff178 100644 --- a/lib/pages/video/zone/video_zone_player_page.dart +++ b/lib/pages/video/zone/video_zone_player_page.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'package:chewie/chewie.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:wow_english/common/request/dao/video_dao.dart'; +import 'package:wow_english/common/utils/video_progress_cache.dart'; import 'video_zone_item.dart'; @@ -23,13 +27,13 @@ class VideoZonePlayerPage extends StatefulWidget { class _VideoZonePlayerPageState extends State with WidgetsBindingObserver { VideoPlayerController? _controller; - Timer? _progressTimer; + ChewieController? _chewieController; + Timer? _cacheTimer; late int _currentIndex; double _playbackSpeed = 1; - bool _showControls = true; bool _switchingVideo = false; bool _handlingCompletion = false; - int? _lastReportedPosition; + bool? _lastIsPlaying; int? _completedReportedVideoId; Object? _loadError; @@ -39,6 +43,9 @@ class _VideoZonePlayerPageState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + unawaited( + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + ); final index = widget.initialVideoId == null ? 0 : widget.items.indexWhere((item) => item.id == widget.initialVideoId); @@ -47,39 +54,88 @@ class _VideoZonePlayerPageState extends State } Future _loadCurrentVideo() async { - _progressTimer?.cancel(); + final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS; + _cacheTimer?.cancel(); final oldController = _controller; + final oldChewieController = _chewieController; + if (oldController?.value.isInitialized == true) { + _playbackSpeed = oldController!.value.playbackSpeed; + } oldController?.removeListener(_onVideoChanged); + _lastIsPlaying = null; final controller = VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); _controller = controller; + _chewieController = null; _loadError = null; if (mounted) { setState(() {}); } + oldChewieController?.dispose(); await oldController?.dispose(); try { - await controller.initialize(); + await controller.initialize().timeout(const Duration(seconds: 20)); if (!mounted || controller != _controller) { await controller.dispose(); return; } await controller.setLooping(false); await controller.setPlaybackSpeed(_playbackSpeed); - final savedProgress = - _currentItem.completed ? 0 : _currentItem.progressSeconds; + final savedProgress = await _resumeProgressFor(_currentItem.id); + if (!mounted || controller != _controller) { + await controller.dispose(); + return; + } if (savedProgress > 0 && savedProgress < controller.value.duration.inSeconds) { await controller.seekTo(Duration(seconds: savedProgress)); } + _lastIsPlaying = controller.value.isPlaying; controller.addListener(_onVideoChanged); - await controller.play(); - _lastReportedPosition = null; + _chewieController = ChewieController( + videoPlayerController: controller, + autoPlay: true, + looping: false, + allowFullScreen: false, + allowPlaybackSpeedChanging: true, + draggableProgressBar: true, + allowedScreenSleep: false, + playbackSpeeds: const [0.5, 0.75, 1, 1.25, 1.5, 2], + customControls: + useCupertinoControls ? null : const _VideoMaterialControls(), + optionsTranslation: OptionsTranslation( + playbackSpeedButtonText: '播放速度', + cancelButtonText: '取消', + ), + materialProgressColors: ChewieProgressColors( + playedColor: const Color(0xFF00B6F1), + handleColor: const Color(0xFF00B6F1), + bufferedColor: Colors.white54, + backgroundColor: Colors.white24, + ), + cupertinoProgressColors: ChewieProgressColors( + playedColor: const Color(0xFF00B6F1), + handleColor: const Color(0xFF00B6F1), + bufferedColor: Colors.white54, + backgroundColor: Colors.white24, + ), + placeholder: const ColoredBox(color: Colors.black), + errorBuilder: (_, __) => const Center( + child: Text( + '视频播放失败', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + ), + ); _handlingCompletion = false; - _progressTimer = Timer.periodic( - const Duration(seconds: 5), - (_) => _reportCurrentProgress(), + _cacheTimer = Timer.periodic( + const Duration(seconds: 30), + (_) { + if (_controller?.value.isPlaying == true) { + unawaited(_cacheCurrentProgress()); + } + }, ); setState(() {}); } catch (error) { @@ -92,21 +148,24 @@ class _VideoZonePlayerPageState extends State void _onVideoChanged() { final controller = _controller; if (!mounted || controller == null) return; + final isPlaying = controller.value.isPlaying; + if (_lastIsPlaying != null && _lastIsPlaying != isPlaying) { + unawaited(_cacheCurrentProgress()); + } + _lastIsPlaying = isPlaying; if (controller.value.isCompleted && !_switchingVideo && !_handlingCompletion && _completedReportedVideoId != _currentItem.id) { _handlingCompletion = true; unawaited(_handleVideoCompleted()); - return; } - setState(() {}); } Future _playNext({bool currentCompleted = false}) async { if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; _switchingVideo = true; - await _reportCurrentProgress(completed: currentCompleted, force: true); + await _reportCurrentProgress(completed: currentCompleted); setState(() => _currentIndex++); await _loadCurrentVideo(); _switchingVideo = false; @@ -116,99 +175,127 @@ class _VideoZonePlayerPageState extends State if (_currentIndex < widget.items.length - 1) { await _playNext(currentCompleted: true); } else { - await _reportCurrentProgress(completed: true, force: true); + await _reportCurrentProgress(completed: true); } _handlingCompletion = false; } - Future _togglePlay() async { - final controller = _controller; - if (controller == null || !controller.value.isInitialized) return; - if (controller.value.isCompleted) { - await controller.seekTo(Duration.zero); - _completedReportedVideoId = null; - } - if (controller.value.isPlaying) { - await controller.pause(); - await _reportCurrentProgress(force: true); - } else { - await controller.play(); - } - if (mounted) setState(() {}); - } - - Future _setSpeed(double speed) async { - _playbackSpeed = speed; - await _controller?.setPlaybackSpeed(speed); - if (mounted) setState(() {}); - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _progressTimer?.cancel(); + unawaited( + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + ); + _cacheTimer?.cancel(); _controller?.removeListener(_onVideoChanged); - unawaited(_reportCurrentProgress(force: true)); + unawaited(_reportCurrentProgress()); + _chewieController?.dispose(); _controller?.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.inactive || + if (state == AppLifecycleState.resumed) { + unawaited( + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + ); + } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { - unawaited(_reportCurrentProgress(force: true)); + unawaited(_cacheCurrentProgress()); } } - Future _reportCurrentProgress({ - bool? completed, - bool force = false, - }) async { + Future _reportCurrentProgress({bool? completed}) async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; - final durationSeconds = controller.value.duration.inSeconds > 0 - ? controller.value.duration.inSeconds - : _currentItem.durationSeconds; + final currentItem = _currentItem; + final durationSeconds = controller.value.duration.inSeconds; final progressSeconds = controller.value.position.inSeconds .clamp(0, durationSeconds > 0 ? durationSeconds : 0); final isCompleted = completed ?? controller.value.isCompleted; - if (!force && _lastReportedPosition == progressSeconds) return; - if (isCompleted && _completedReportedVideoId == _currentItem.id) return; - _lastReportedPosition = progressSeconds; + await _persistCachedProgress( + videoId: currentItem.id, + progressSeconds: progressSeconds, + durationSeconds: durationSeconds, + completed: isCompleted, + ); + if (isCompleted && _completedReportedVideoId == currentItem.id) return; try { await VideoDao.savePlayRecord( - videoId: _currentItem.id, + videoId: currentItem.id, progressSeconds: progressSeconds, durationSeconds: durationSeconds, completed: isCompleted, ); - if (isCompleted) _completedReportedVideoId = _currentItem.id; + if (isCompleted) _completedReportedVideoId = currentItem.id; } catch (error) { debugPrint('保存视频播放记录失败: $error'); } } + Future _resumeProgressFor(int videoId) async { + try { + final cached = await VideoProgressCache.get(videoId); + if (cached != null) return cached.progressSeconds; + } catch (error) { + debugPrint('读取本地视频播放进度失败: $error'); + } + return 0; + } + + Future _cacheCurrentProgress() async { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + final currentItem = _currentItem; + final durationSeconds = controller.value.duration.inSeconds; + final progressSeconds = controller.value.position.inSeconds + .clamp(0, durationSeconds > 0 ? durationSeconds : 0); + await _persistCachedProgress( + videoId: currentItem.id, + progressSeconds: progressSeconds, + durationSeconds: durationSeconds, + completed: controller.value.isCompleted, + ); + } + + Future _persistCachedProgress({ + required int videoId, + required int progressSeconds, + required int durationSeconds, + required bool completed, + }) async { + try { + if (completed) { + await VideoProgressCache.remove(videoId); + } else { + await VideoProgressCache.save( + videoId: videoId, + progressSeconds: progressSeconds, + durationSeconds: durationSeconds, + ); + } + } catch (error) { + debugPrint('保存本地视频播放进度失败: $error'); + } + } + @override Widget build(BuildContext context) { final controller = _controller; - return Scaffold( - backgroundColor: Colors.black, - body: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _showControls = !_showControls), - onDoubleTap: _togglePlay, - child: Stack( + final chewieController = _chewieController; + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( fit: StackFit.expand, children: [ - if (controller?.value.isInitialized == true) - Center( - child: AspectRatio( - aspectRatio: controller!.value.aspectRatio, - child: VideoPlayer(controller), - ), - ) + if (controller?.value.isInitialized == true && + chewieController != null) + Chewie(controller: chewieController) else if (_loadError != null) Center( child: Column( @@ -224,109 +311,52 @@ class _VideoZonePlayerPageState extends State ) else const Center(child: CircularProgressIndicator()), - if (_showControls) _buildControls(controller), - ], - ), - ), - ); - } - - Widget _buildControls(VideoPlayerController? controller) { - final initialized = controller?.value.isInitialized == true; - return ColoredBox( - color: Colors.black38, - child: SafeArea( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: - const Icon(Icons.arrow_back_ios_new, color: Colors.white), - ), - Expanded( - child: Text( - _currentItem.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 20), - ), - ), - PopupMenuButton( - initialValue: _playbackSpeed, - onSelected: _setSpeed, - itemBuilder: (_) => const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] - .map((speed) => PopupMenuItem( - value: speed, - child: Text('${speed}x'), - )) - .toList(), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 18, vertical: 12), - child: Text('${_playbackSpeed}x', - style: - const TextStyle(color: Colors.white, fontSize: 16)), - ), - ), - ], - ), - const Spacer(), - if (initialized) - IconButton( - onPressed: _togglePlay, - iconSize: 68, - color: Colors.white, - icon: Icon(controller!.value.isPlaying - ? Icons.pause_circle_filled - : Icons.play_circle_fill), - ), - const Spacer(), - if (initialized) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + SafeArea( + top: false, + child: Align( + alignment: Alignment.topLeft, child: Row( children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.white), + ), Expanded( - child: VideoProgressIndicator( - controller!, - allowScrubbing: true, - padding: const EdgeInsets.symmetric(vertical: 12), - colors: const VideoProgressColors( - playedColor: Color(0xFF00B6F1), - bufferedColor: Colors.white54, - backgroundColor: Colors.white24, - ), + child: Text( + _currentItem.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: Colors.white, fontSize: 20), ), ), - const SizedBox(width: 12), - Text( - '${_formatDuration(controller.value.position)} / ' - '${_formatDuration(controller.value.duration)}', - style: const TextStyle(color: Colors.white), - ), - if (_currentIndex < widget.items.length - 1) ...[ - const SizedBox(width: 8), - IconButton( - tooltip: '下一集', - onPressed: _playNext, - icon: const Icon(Icons.skip_next, color: Colors.white), - ), - ], ], ), ), + ), ], ), ), ); } +} - String _formatDuration(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - if (duration.inHours == 0) return '$minutes:$seconds'; - return '${duration.inHours.toString().padLeft(2, '0')}:$minutes:$seconds'; +class _VideoMaterialControls extends StatelessWidget { + const _VideoMaterialControls(); + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + minimumSize: const Size.square(72), + padding: const EdgeInsets.all(20), + ), + ), + ), + child: const MaterialControls(), + ); } } diff --git a/pubspec.lock b/pubspec.lock index 88d618b..5a9a3d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.3" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "8bc4ac4cf3f316e50a25958c0f5eb9bb12cf7e8308bb1d74a43b230da2cfc144" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.7.5" chivox_aiengine: dependency: "direct main" description: @@ -1301,7 +1309,7 @@ packages: source: hosted version: "7.0.0" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d @@ -1540,6 +1548,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "14.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.4" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d1335a..27468c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,8 @@ dependencies: pull_to_refresh: ^2.0.0 # 数据持久化 https://pub.dev/packages/shared_preferences shared_preferences: ^2.1.2 + # SQLite 数据库 https://pub.dev/packages/sqflite + sqflite: ^2.3.3+1 #字体/尺寸适配 https://pub.dev/packages/flutter_screenutil flutter_screenutil: ^5.8.4 # 显示网络等待插件 https://pub.flutter-io.cn/packages/flutter_easyloading @@ -94,6 +96,8 @@ dependencies: extended_text: ^11.0.1 # 视频播放 https://pub.dev/packages/video_player video_player: 2.9.2 + # 视频播放器控制层 https://pub.dev/packages/chewie + chewie: 1.7.5 # 驰声语音评测 SDK(官方离线包裁剪为当前支持架构) chivox_aiengine: path: packages/chivox_aiengine -- libgit2 0.22.2