diff --git a/lib/bean/card/bangumi_history_card.dart b/lib/bean/card/bangumi_history_card.dart index feee95ac6..b1b102f6b 100644 --- a/lib/bean/card/bangumi_history_card.dart +++ b/lib/bean/card/bangumi_history_card.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'dart:io'; import 'package:kazumi/bean/card/network_img_layer.dart'; import 'package:kazumi/bean/dialog/dialog_helper.dart'; import 'package:kazumi/bean/widget/collect_button.dart'; import 'package:kazumi/modules/history/history_module.dart'; +import 'package:kazumi/services/local_video_picker_service.dart'; import 'package:kazumi/pages/collect/collect_controller.dart'; import 'package:kazumi/pages/history/history_controller.dart'; import 'package:kazumi/pages/video/video_controller.dart'; @@ -36,12 +38,50 @@ class _BangumiHistoryCardVState extends State { final PluginsController pluginsController = Modular.get(); final HistoryController historyController = Modular.get(); final CollectController collectController = Modular.get(); + bool _opening = false; Future _onTap() async { + if (_opening) { + return; + } if (widget.showDelete) { KazumiDialog.showToast(message: '编辑模式'); return; } + _opening = true; + if (widget.historyItem.isLocalVideo) { + final progress = + widget.historyItem.progresses[widget.historyItem.lastWatchEpisode]; + final localPath = (progress?.localPath.isNotEmpty ?? false) + ? progress!.localPath + : widget.historyItem.localVideoPath.isNotEmpty + ? widget.historyItem.localVideoPath + : widget.historyItem.lastSrc; + if (localPath.isEmpty || !File(localPath).existsSync()) { + KazumiDialog.showToast(message: '本地文件不存在或已移动'); + _opening = false; + return; + } + final episodeTitle = (progress?.episodeTitle.isNotEmpty ?? false) + ? progress!.episodeTitle + : widget.historyItem.lastWatchEpisodeName.isNotEmpty + ? widget.historyItem.lastWatchEpisodeName + : widget.historyItem.localVideoTitle; + videoPageController.initForLocalFilePlayback( + context: LocalVideoPickerService().buildContext(localPath).copyWith( + title: episodeTitle.isEmpty + ? widget.historyItem.localVideoTitle + : episodeTitle, + ), + boundBangumiItem: widget.historyItem.isBoundLocalVideo + ? widget.historyItem.bangumiItem + : null, + episodeNumber: widget.historyItem.lastWatchEpisode, + ); + Modular.to.pushNamed('/video/'); + _opening = false; + return; + } KazumiDialog.showLoading( msg: '获取中', barrierDismissible: Utils.isDesktop(), @@ -60,22 +100,24 @@ class _BangumiHistoryCardVState extends State { if (!flag) { KazumiDialog.dismiss(); KazumiDialog.showToast(message: '未找到关联番剧源'); + _opening = false; return; } videoPageController.bangumiItem = widget.historyItem.bangumiItem; - videoPageController.title = - widget.historyItem.bangumiItem.nameCn == '' - ? widget.historyItem.bangumiItem.name - : widget.historyItem.bangumiItem.nameCn; + videoPageController.title = widget.historyItem.bangumiItem.nameCn == '' + ? widget.historyItem.bangumiItem.name + : widget.historyItem.bangumiItem.nameCn; videoPageController.src = widget.historyItem.lastSrc; try { - await videoPageController.queryRoads(widget.historyItem.lastSrc, - videoPageController.currentPlugin.name); + await videoPageController.queryRoads( + widget.historyItem.lastSrc, videoPageController.currentPlugin.name); KazumiDialog.dismiss(); Modular.to.pushNamed('/video/'); + _opening = false; } catch (_) { KazumiLogger().w("QueryManager: failed to query roads"); KazumiDialog.dismiss(); + _opening = false; } } @@ -85,13 +127,33 @@ class _BangumiHistoryCardVState extends State { final colorScheme = theme.colorScheme; final double imageWidth = 80; final double imageHeight = 108; - final String title = widget.historyItem.bangumiItem.nameCn == '' - ? widget.historyItem.bangumiItem.name - : widget.historyItem.bangumiItem.nameCn; - final String episodeText = - widget.historyItem.lastWatchEpisodeName.isEmpty - ? '第${widget.historyItem.lastWatchEpisode}话' - : widget.historyItem.lastWatchEpisodeName; + final progress = + widget.historyItem.progresses[widget.historyItem.lastWatchEpisode]; + final localPath = (progress?.localPath.isNotEmpty ?? false) + ? progress!.localPath + : widget.historyItem.localVideoPath.isNotEmpty + ? widget.historyItem.localVideoPath + : widget.historyItem.lastSrc; + final localFileName = widget.historyItem.localVideoFileName.isNotEmpty + ? widget.historyItem.localVideoFileName + : localPath.split(RegExp(r'[\\/]')).last; + final String title = widget.historyItem.isLocalVideo + ? (localFileName.isNotEmpty + ? localFileName + : widget.historyItem.localVideoTitle) + : widget.historyItem.bangumiItem.nameCn == '' + ? widget.historyItem.bangumiItem.name + : widget.historyItem.bangumiItem.nameCn; + final String episodeText = widget.historyItem.lastWatchEpisodeName.isEmpty + ? '第${widget.historyItem.lastWatchEpisode}集' + : widget.historyItem.lastWatchEpisodeName; + final showEpisodeText = !widget.historyItem.isLocalVideo || + widget.historyItem.isBoundLocalVideo; + final sourceText = + widget.historyItem.isLocalVideo ? '本地' : widget.historyItem.adapterName; + final sourceIcon = widget.historyItem.isLocalVideo + ? Icons.movie_outlined + : Icons.extension_outlined; return Dismissible( key: ValueKey(widget.historyItem.key), @@ -151,39 +213,41 @@ class _BangumiHistoryCardVState extends State { overflow: TextOverflow.ellipsis, maxLines: 1, ), - const SizedBox(height: 6), - Row( - children: [ - Icon( - Icons.play_circle_outline, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - episodeText, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + if (showEpisodeText) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.play_circle_outline, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + episodeText, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), - ), - ], - ), + ], + ), + ], const SizedBox(height: 4), Row( children: [ Icon( - Icons.extension_outlined, + sourceIcon, size: 14, color: colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Flexible( child: Text( - widget.historyItem.adapterName, + sourceText, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -203,8 +267,11 @@ class _BangumiHistoryCardVState extends State { ), const SizedBox(width: 4), Text( - Utils.formatTimestampToRelativeTime( - widget.historyItem.lastWatchTime.millisecondsSinceEpoch ~/ 1000), + Utils.formatTimestampToRelativeTime(widget + .historyItem + .lastWatchTime + .millisecondsSinceEpoch ~/ + 1000), style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.outline, ), diff --git a/lib/bean/card/network_img_layer.dart b/lib/bean/card/network_img_layer.dart index 487e527f1..6ba6c5a06 100644 --- a/lib/bean/card/network_img_layer.dart +++ b/lib/bean/card/network_img_layer.dart @@ -32,9 +32,13 @@ class NetworkImgLayer extends StatelessWidget { //// We need this to shink memory usage int? memCacheWidth, memCacheHeight; - double aspectRatio = (width / height).toDouble(); void setMemCacheSizes() { + if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { + return; + } + + final double aspectRatio = (width / height).toDouble(); if (aspectRatio > 1) { memCacheHeight = height.cacheSize(context); } else if (aspectRatio < 1) { @@ -54,7 +58,12 @@ class NetworkImgLayer extends StatelessWidget { setMemCacheSizes(); if (memCacheWidth == null && memCacheHeight == null) { - memCacheWidth = width.toInt(); + if (width.isFinite && width > 0) { + final fallbackWidth = width.toInt(); + if (fallbackWidth > 0) { + memCacheWidth = fallbackWidth; + } + } } return src != '' && src != null @@ -80,7 +89,8 @@ class NetworkImgLayer extends StatelessWidget { fadeInDuration ?? const Duration(milliseconds: 120), filterQuality: FilterQuality.high, errorListener: (e) { - KazumiLogger().w("NetworkImage: network image load error", error: e); + KazumiLogger() + .w("NetworkImage: network image load error", error: e); }, errorWidget: (BuildContext context, String url, Object error) => placeholder(context), @@ -96,7 +106,10 @@ class NetworkImgLayer extends StatelessWidget { height: height, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onInverseSurface.withValues(alpha: 0.4), + color: Theme.of(context) + .colorScheme + .onInverseSurface + .withValues(alpha: 0.4), borderRadius: BorderRadius.circular(type == 'avatar' ? 50 : type == 'emote' diff --git a/lib/modules/history/history_module.dart b/lib/modules/history/history_module.dart index ff9613a1e..018264c6d 100644 --- a/lib/modules/history/history_module.dart +++ b/lib/modules/history/history_module.dart @@ -1,5 +1,6 @@ import 'package:hive_ce/hive.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; part 'history_module.g.dart'; @@ -26,12 +27,39 @@ class History { @HiveField(6, defaultValue: '') String lastWatchEpisodeName; - String get key => adapterName + bangumiItem.id.toString(); + @HiveField(7, defaultValue: 'online') + String sourceTypeName; - History( - this.bangumiItem, this.lastWatchEpisode, this.adapterName, this.lastWatchTime, this.lastSrc, this.lastWatchEpisodeName); + @HiveField(8, defaultValue: '') + String localVideoPath; - static String getKey(String n, BangumiItem s) => n + s.id.toString(); + @HiveField(9, defaultValue: '') + String localVideoTitle; + + @HiveField(10, defaultValue: '') + String localVideoFileName; + + bool get isLocalVideo => sourceTypeName == PlaybackSourceType.localFile.name; + + bool get isBoundLocalVideo => isLocalVideo && bangumiItem.id > 0; + + String get key => isLocalVideo + ? '$adapterName$localVideoPath' + : adapterName + bangumiItem.id.toString(); + + History(this.bangumiItem, this.lastWatchEpisode, this.adapterName, + this.lastWatchTime, this.lastSrc, this.lastWatchEpisodeName, + {this.sourceTypeName = 'online', + this.localVideoPath = '', + this.localVideoTitle = '', + this.localVideoFileName = ''}); + + static String getKey(String n, BangumiItem s, + {String sourceTypeName = 'online', String localVideoPath = ''}) { + return sourceTypeName == PlaybackSourceType.localFile.name + ? '$n$localVideoPath' + : n + s.id.toString(); + } @override String toString() { @@ -50,11 +78,23 @@ class Progress { @HiveField(2) int _progressInMilli; + @HiveField(3, defaultValue: '') + String localPath; + + @HiveField(4, defaultValue: '') + String episodeTitle; + Duration get progress => Duration(milliseconds: _progressInMilli); set progress(Duration d) => _progressInMilli = d.inMilliseconds; - Progress(this.episode, this.road, this._progressInMilli); + Progress( + this.episode, + this.road, + this._progressInMilli, { + this.localPath = '', + this.episodeTitle = '', + }); @override String toString() { diff --git a/lib/modules/history/history_module.g.dart b/lib/modules/history/history_module.g.dart index 38268d5d0..d7157499c 100644 --- a/lib/modules/history/history_module.g.dart +++ b/lib/modules/history/history_module.g.dart @@ -23,13 +23,17 @@ class HistoryAdapter extends TypeAdapter { fields[4] as DateTime, fields[5] as String, fields[6] == null ? '' : fields[6] as String, + sourceTypeName: fields[7] == null ? 'online' : fields[7] as String, + localVideoPath: fields[8] == null ? '' : fields[8] as String, + localVideoTitle: fields[9] == null ? '' : fields[9] as String, + localVideoFileName: fields[10] == null ? '' : fields[10] as String, )..progresses = (fields[0] as Map).cast(); } @override void write(BinaryWriter writer, History obj) { writer - ..writeByte(7) + ..writeByte(11) ..writeByte(0) ..write(obj.progresses) ..writeByte(1) @@ -43,7 +47,15 @@ class HistoryAdapter extends TypeAdapter { ..writeByte(5) ..write(obj.lastSrc) ..writeByte(6) - ..write(obj.lastWatchEpisodeName); + ..write(obj.lastWatchEpisodeName) + ..writeByte(7) + ..write(obj.sourceTypeName) + ..writeByte(8) + ..write(obj.localVideoPath) + ..writeByte(9) + ..write(obj.localVideoTitle) + ..writeByte(10) + ..write(obj.localVideoFileName); } @override @@ -71,19 +83,25 @@ class ProgressAdapter extends TypeAdapter { (fields[0] as num).toInt(), (fields[1] as num).toInt(), (fields[2] as num).toInt(), + localPath: fields[3] == null ? '' : fields[3] as String, + episodeTitle: fields[4] == null ? '' : fields[4] as String, ); } @override void write(BinaryWriter writer, Progress obj) { writer - ..writeByte(3) + ..writeByte(5) ..writeByte(0) ..write(obj.episode) ..writeByte(1) ..write(obj.road) ..writeByte(2) - ..write(obj._progressInMilli); + ..write(obj._progressInMilli) + ..writeByte(3) + ..write(obj.localPath) + ..writeByte(4) + ..write(obj.episodeTitle); } @override diff --git a/lib/modules/playback/playback_source.dart b/lib/modules/playback/playback_source.dart new file mode 100644 index 000000000..3e017200b --- /dev/null +++ b/lib/modules/playback/playback_source.dart @@ -0,0 +1,51 @@ +import 'package:kazumi/modules/bangumi/bangumi_item.dart'; + +const String localVideoHistoryAdapterName = 'local_video'; + +enum PlaybackSourceType { + online, + downloaded, + localFile, +} + +class LocalVideoPlaybackContext { + final String path; + final String title; + final String fileName; + final int fileSize; + final DateTime? lastModified; + final BangumiItem? boundBangumiItem; + final int? boundEpisode; + + const LocalVideoPlaybackContext({ + required this.path, + required this.title, + required this.fileName, + required this.fileSize, + required this.lastModified, + this.boundBangumiItem, + this.boundEpisode, + }); + + bool get hasBangumiBinding => boundBangumiItem != null; + + LocalVideoPlaybackContext copyWith({ + String? title, + BangumiItem? boundBangumiItem, + int? boundEpisode, + bool clearBangumiBinding = false, + }) { + return LocalVideoPlaybackContext( + path: path, + title: title ?? this.title, + fileName: fileName, + fileSize: fileSize, + lastModified: lastModified, + boundBangumiItem: clearBangumiBinding + ? null + : boundBangumiItem ?? this.boundBangumiItem, + boundEpisode: + clearBangumiBinding ? null : boundEpisode ?? this.boundEpisode, + ); + } +} diff --git a/lib/pages/history/history_controller.dart b/lib/pages/history/history_controller.dart index 1bef243d8..1a714e862 100644 --- a/lib/pages/history/history_controller.dart +++ b/lib/pages/history/history_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/modules/history/history_module.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; import 'package:kazumi/repositories/history_repository.dart'; import 'package:mobx/mobx.dart'; @@ -12,7 +13,7 @@ abstract class _HistoryController with Store { final _historyRepository = Modular.get(); @observable - ObservableList histories = ObservableList(); + ObservableList histories = ObservableList(); void init() { final temp = _historyRepository.getAllHistories(); @@ -21,7 +22,17 @@ abstract class _HistoryController with Store { } Future updateHistory( - int episode, int road, String adapterName, BangumiItem bangumiItem, Duration progress, String lastSrc, String lastWatchEpisodeName) async { + int episode, + int road, + String adapterName, + BangumiItem bangumiItem, + Duration progress, + String lastSrc, + String lastWatchEpisodeName, + {String localPath = '', + String episodeTitle = '', + PlaybackSourceType sourceType = PlaybackSourceType.online, + LocalVideoPlaybackContext? localVideoContext}) async { await _historyRepository.updateHistory( episode: episode, road: road, @@ -30,6 +41,10 @@ abstract class _HistoryController with Store { progress: progress, lastSrc: lastSrc, lastWatchEpisodeName: lastWatchEpisodeName, + localPath: localPath, + episodeTitle: episodeTitle, + sourceType: sourceType, + localVideoContext: localVideoContext, ); init(); } @@ -38,7 +53,8 @@ abstract class _HistoryController with Store { return _historyRepository.getLastWatchingProgress(bangumiItem, adapterName); } - Progress? findProgress(BangumiItem bangumiItem, String adapterName, int episode) { + Progress? findProgress( + BangumiItem bangumiItem, String adapterName, int episode) { return _historyRepository.findProgress(bangumiItem, adapterName, episode); } @@ -47,7 +63,8 @@ abstract class _HistoryController with Store { init(); } - Future clearProgress(BangumiItem bangumiItem, String adapterName, int episode) async { + Future clearProgress( + BangumiItem bangumiItem, String adapterName, int episode) async { await _historyRepository.clearProgress(bangumiItem, adapterName, episode); init(); } diff --git a/lib/pages/menu/menu.dart b/lib/pages/menu/menu.dart index ea12a40b9..bc00b561a 100644 --- a/lib/pages/menu/menu.dart +++ b/lib/pages/menu/menu.dart @@ -1,7 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:kazumi/bean/dialog/dialog_helper.dart'; import 'package:kazumi/bean/widget/embedded_native_control_area.dart'; +import 'package:kazumi/modules/bangumi/bangumi_item.dart'; +import 'package:kazumi/modules/bangumi/episode_item.dart'; +import 'package:kazumi/modules/history/history_module.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; +import 'package:kazumi/pages/history/history_controller.dart'; import 'package:kazumi/pages/router.dart'; +import 'package:kazumi/pages/video/video_controller.dart'; +import 'package:kazumi/request/apis/bangumi_api.dart'; +import 'package:kazumi/services/local_video_picker_service.dart'; import 'package:kazumi/utils/storage.dart'; import 'package:provider/provider.dart'; @@ -59,6 +68,418 @@ class NavigationBarState extends ChangeNotifier { class _ScaffoldMenu extends State { final PageController _page = PageController(); + final VideoPageController videoPageController = + Modular.get(); + final HistoryController historyController = Modular.get(); + final LocalVideoPickerService localVideoPickerService = + LocalVideoPickerService(); + bool _openingLocalVideo = false; + + String _normalizeLocalVideoPath(String path) { + return path.replaceAll('\\', '/').toLowerCase(); + } + + History? _findLocalFileHistory(String videoPath) { + final normalizedVideoPath = _normalizeLocalVideoPath(videoPath); + for (final history in historyController.histories) { + if (!history.isLocalVideo) { + continue; + } + if (_normalizeLocalVideoPath(history.localVideoPath) == + normalizedVideoPath || + _normalizeLocalVideoPath(history.lastSrc) == normalizedVideoPath) { + return history; + } + for (final progress in history.progresses.values) { + if (_normalizeLocalVideoPath(progress.localPath) == + normalizedVideoPath) { + return history; + } + } + } + return null; + } + + Future _openLocalVideo() async { + if (_openingLocalVideo) { + return; + } + _openingLocalVideo = true; + final context = await localVideoPickerService.pickVideo(); + if (context == null) { + _openingLocalVideo = false; + return; + } + + try { + historyController.init(); + await _showLocalVideoSheet(context); + } finally { + _openingLocalVideo = false; + } + } + + Future _showLocalVideoSheet( + LocalVideoPlaybackContext localVideoContext) async { + final displayTitle = + localVideoContext.title.isEmpty ? '本地视频' : localVideoContext.title; + final videoPath = localVideoContext.path; + final history = _findLocalFileHistory(videoPath); + final historyProgress = history?.progresses[history.lastWatchEpisode]; + BangumiItem? selectedBangumi = + history?.isBoundLocalVideo == true ? history!.bangumiItem : null; + var episodeNumber = history?.lastWatchEpisode ?? 1; + if (episodeNumber < 1) { + episodeNumber = 1; + } + + var results = []; + var searching = false; + var episodeLoading = false; + var episodeLoadFailed = false; + var openingPlayback = false; + var shouldOpenPlayback = false; + var episodeList = []; + int? selectedEpisode = selectedBangumi == null ? null : episodeNumber; + final searchController = TextEditingController(); + final episodeController = + TextEditingController(text: episodeNumber.toString()); + + Future startPlayback({ + required BuildContext sheetContext, + required bool skipBinding, + }) async { + if (openingPlayback) { + return; + } + openingPlayback = true; + final parsedEpisode = + selectedEpisode ?? int.tryParse(episodeController.text.trim()) ?? 1; + videoPageController.initForLocalFilePlayback( + context: localVideoContext.copyWith( + title: (historyProgress?.episodeTitle.isNotEmpty ?? false) + ? historyProgress!.episodeTitle + : displayTitle, + ), + boundBangumiItem: skipBinding ? null : selectedBangumi, + episodeNumber: parsedEpisode < 1 ? 1 : parsedEpisode, + ); + if (!sheetContext.mounted) { + return; + } + shouldOpenPlayback = true; + Navigator.of(sheetContext).pop(); + } + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + Future searchBangumi() async { + final keyword = searchController.text.trim(); + if (keyword.isEmpty) { + return; + } + setModalState(() { + searching = true; + }); + try { + final value = await BangumiApi.bangumiSearch(keyword); + if (!context.mounted) { + return; + } + setModalState(() { + results = value; + searching = false; + }); + if (value.isEmpty) { + KazumiDialog.showToast(message: '未找到匹配结果'); + } + } catch (e) { + if (!context.mounted) { + return; + } + setModalState(() { + searching = false; + }); + KazumiDialog.showToast(message: '搜索失败'); + } + } + + Future selectBangumi(BangumiItem item) async { + setModalState(() { + selectedBangumi = item; + selectedEpisode = null; + results = []; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = true; + }); + try { + final value = await BangumiApi.getBangumiEpisodesByID(item.id); + if (!context.mounted) { + return; + } + setModalState(() { + episodeList = value + .where((episode) => episode.type == 0) + .where((episode) => episode.episode > 0) + .toList(); + episodeLoading = false; + episodeLoadFailed = episodeList.isEmpty; + }); + if (episodeList.isEmpty) { + KazumiDialog.showToast(message: '未获取到集数,请手动输入'); + } + } catch (e) { + if (!context.mounted) { + return; + } + setModalState(() { + episodeList = []; + episodeLoading = false; + episodeLoadFailed = true; + }); + KazumiDialog.showToast(message: '未获取到集数,请手动输入'); + } + } + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '匹配番剧', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + onPressed: openingPlayback + ? null + : () => startPlayback( + sheetContext: context, + skipBinding: true, + ), + child: const Text('不绑定'), + ), + IconButton( + tooltip: '关闭', + onPressed: openingPlayback + ? null + : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Text( + displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + labelText: 'Bangumi 搜索', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: '清除', + onPressed: () { + setModalState(() { + searchController.clear(); + selectedBangumi = null; + selectedEpisode = null; + results = []; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = false; + }); + }, + icon: const Icon(Icons.clear), + ), + ), + onChanged: (_) { + if (selectedBangumi == null) { + return; + } + setModalState(() { + selectedBangumi = null; + selectedEpisode = null; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = false; + }); + }, + onSubmitted: (_) => searchBangumi(), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: searching ? null : searchBangumi, + tooltip: '搜索', + icon: searching + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.search), + ), + ], + ), + if (selectedBangumi != null) ...[ + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.check_circle_outline), + title: Text( + selectedBangumi!.nameCn.isNotEmpty + ? selectedBangumi!.nameCn + : selectedBangumi!.name, + ), + subtitle: Text( + selectedEpisode == null && !episodeLoadFailed + ? '已选择绑定条目' + : '已选择绑定条目 · 第${selectedEpisode ?? int.tryParse(episodeController.text.trim()) ?? 1}集', + ), + ), + const SizedBox(height: 12), + if (episodeLoading) + const Center(child: CircularProgressIndicator()) + else if (episodeList.isNotEmpty) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 216), + child: LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = (constraints.maxWidth / 72) + .floor() + .clamp(4, 8) + .toInt(); + return GridView.builder( + shrinkWrap: true, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 48, + ), + itemCount: episodeList.length, + itemBuilder: (context, index) { + final episode = + episodeList[index].episode.toInt(); + final selected = selectedEpisode == episode; + return selected + ? FilledButton.tonal( + onPressed: () {}, + child: Text(episode.toString()), + ) + : OutlinedButton( + onPressed: () { + setModalState(() { + selectedEpisode = episode; + episodeController.text = + episode.toString(); + }); + }, + child: Text(episode.toString()), + ); + }, + ); + }, + ), + ) + else if (episodeLoadFailed) + SizedBox( + width: 120, + child: TextField( + controller: episodeController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '集数', + border: OutlineInputBorder(), + ), + ), + ), + ], + if (results.isNotEmpty) ...[ + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: ListView.builder( + shrinkWrap: true, + itemCount: results.length, + itemBuilder: (context, index) { + final item = results[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + item.nameCn.isNotEmpty + ? item.nameCn + : item.name, + ), + subtitle: Text( + item.airDate, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + selectBangumi(item); + }, + ); + }, + ), + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: openingPlayback || + selectedBangumi == null || + (selectedEpisode == null && + !episodeLoadFailed) + ? null + : () => startPlayback( + sheetContext: context, + skipBinding: false, + ), + child: const Text('绑定并播放'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + if (shouldOpenPlayback) { + await Future.delayed(Duration.zero); + Modular.to.pushNamed('/video/'); + } + } @override Widget build(BuildContext context) { @@ -85,6 +506,14 @@ class _ScaffoldMenu extends State { itemBuilder: (_, __) => const RouterOutlet(), ), ), + floatingActionButton: state.isHide + ? null + : FloatingActionButton.small( + heroTag: 'openLocalVideoBottom', + tooltip: '打开本地视频', + onPressed: _openLocalVideo, + child: const Icon(Icons.video_file_outlined), + ), bottomNavigationBar: state.isHide ? const SizedBox(height: 0) : NavigationBar( @@ -129,13 +558,27 @@ class _ScaffoldMenu extends State { child: NavigationRail( backgroundColor: Theme.of(context).colorScheme.surfaceContainer, groupAlignment: 1.0, - leading: FloatingActionButton( - elevation: 0, - heroTag: null, - onPressed: () { - Modular.to.pushNamed('/search/'); - }, - child: const Icon(Icons.search), + leading: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + elevation: 0, + heroTag: 'search', + tooltip: '搜索', + onPressed: () { + Modular.to.pushNamed('/search/'); + }, + child: const Icon(Icons.search), + ), + const SizedBox(height: 12), + FloatingActionButton.small( + elevation: 0, + heroTag: 'openLocalVideoRail', + tooltip: '打开本地视频', + onPressed: _openLocalVideo, + child: const Icon(Icons.video_file_outlined), + ), + ], ), labelType: NavigationRailLabelType.selected, destinations: const [ @@ -194,4 +637,4 @@ class _ScaffoldMenu extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/player/episode_comments_sheet.dart b/lib/pages/player/episode_comments_sheet.dart index d6ecf09bd..5403c3354 100644 --- a/lib/pages/player/episode_comments_sheet.dart +++ b/lib/pages/player/episode_comments_sheet.dart @@ -50,6 +50,13 @@ class _EpisodeCommentsSheetState extends State { } Future loadComments(int episode) async { + if (videoPageController.bangumiItem.id <= 0) { + setState(() { + commentsIsEmpty = true; + commentsQueryTimeout = false; + }); + return; + } final int requestId = ++_loadCommentsRequestId; commentsQueryTimeout = false; commentsIsEmpty = false; @@ -85,6 +92,9 @@ class _EpisodeCommentsSheetState extends State { ep = 0; // wait until currentState is not null WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } if (videoPageController.episodeCommentsList.isEmpty) { // trigger RefreshIndicator onRefresh and show animation _refreshIndicatorKey.currentState?.show(); diff --git a/lib/pages/player/player_controller.dart b/lib/pages/player/player_controller.dart index c839d66ec..a7abc3753 100644 --- a/lib/pages/player/player_controller.dart +++ b/lib/pages/player/player_controller.dart @@ -22,6 +22,7 @@ import 'package:kazumi/utils/syncplay_endpoint.dart'; import 'package:kazumi/utils/external_player.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:kazumi/pages/download/download_controller.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; part 'player_controller.g.dart'; @@ -29,6 +30,8 @@ class PlaybackInitParams { final String videoUrl; final int offset; final bool isLocalPlayback; + final PlaybackSourceType sourceType; + final LocalVideoPlaybackContext? localVideoContext; final int bangumiId; final String pluginName; final int episode; @@ -44,6 +47,7 @@ class PlaybackInitParams { required this.videoUrl, required this.offset, required this.isLocalPlayback, + required this.sourceType, required this.bangumiId, required this.pluginName, required this.episode, @@ -54,6 +58,7 @@ class PlaybackInitParams { required this.currentRoad, this.coverUrl, this.bangumiName, + this.localVideoContext, }); } @@ -228,10 +233,17 @@ abstract class _PlayerController with Store { StreamSubscription? playerAudioBitrateSubscription; bool isLocalPlayback = false; + PlaybackSourceType sourceType = PlaybackSourceType.online; + LocalVideoPlaybackContext? localVideoContext; + @observable + int playbackSession = 0; Future init(PlaybackInitParams params) async { + playbackSession++; videoUrl = params.videoUrl; isLocalPlayback = params.isLocalPlayback; + sourceType = params.sourceType; + localVideoContext = params.localVideoContext; bangumiId = params.bangumiId; currentEpisode = params.episode; currentRoad = params.currentRoad; @@ -247,6 +259,7 @@ abstract class _PlayerController with Store { buffer = Duration.zero; duration = Duration.zero; completed = false; + danDanmakus.clear(); playerLogLevel = setting.get(SettingBoxKey.playerLogLevel, defaultValue: 2); playerSpeed = setting.get(SettingBoxKey.defaultPlaySpeed, defaultValue: 1.0); @@ -257,6 +270,8 @@ abstract class _PlayerController with Store { setting.get(SettingBoxKey.buttonSkipTime, defaultValue: 80); arrowKeySkipTime = setting.get(SettingBoxKey.arrowKeySkipTime, defaultValue: 10); + videoController = null; + await Future.delayed(Duration.zero); try { await dispose(disposeSyncPlayController: false); } catch (_) {} @@ -271,7 +286,13 @@ abstract class _PlayerController with Store { if (episodeFromTitle == 0) { episodeFromTitle = params.episode; } - _loadDanmaku(params.bangumiId, params.pluginName, episodeFromTitle); + if (params.sourceType == PlaybackSourceType.localFile && + params.localVideoContext?.boundBangumiItem == null) { + danDanmakus.clear(); + danmakuOn = false; + } else { + _loadDanmaku(params.bangumiId, params.pluginName, episodeFromTitle); + } mediaPlayer ??= await createVideoController( params.httpHeaders, params.adBlockerEnabled, @@ -290,6 +311,7 @@ abstract class _PlayerController with Store { } setPlaybackSpeed(playerSpeed); KazumiLogger().i('PlayerController: video initialized'); + isBuffering = false; loading = false; coverUrl = params.coverUrl; @@ -634,9 +656,9 @@ abstract class _PlayerController with Store { try { await cancelPlayerDebugInfoSubscription(); } catch (_) {} + videoController = null; await mediaPlayer?.dispose(); mediaPlayer = null; - videoController = null; } Future stop() async { @@ -663,6 +685,11 @@ abstract class _PlayerController with Store { /// 加载弹幕 (离线模式优先从缓存加载,无缓存时尝试在线获取) Future _loadDanmaku( int bangumiId, String pluginName, int episode) async { + if (bangumiId <= 0) { + danDanmakus.clear(); + danmakuOn = false; + return; + } if (isLocalPlayback) { await _loadCachedDanmaku(bangumiId, pluginName, episode); } else { @@ -789,6 +816,7 @@ abstract class _PlayerController with Store { } void addDanmakus(List danmakus) { + danDanmakus.clear(); final bool danmakuDeduplicationEnable = setting.get(SettingBoxKey.danmakuDeduplication, defaultValue: false); diff --git a/lib/pages/player/player_controller.g.dart b/lib/pages/player/player_controller.g.dart index 4f8355059..aa7380f9c 100644 --- a/lib/pages/player/player_controller.g.dart +++ b/lib/pages/player/player_controller.g.dart @@ -569,6 +569,22 @@ mixin _$PlayerController on _PlayerController, Store { }); } + late final _$playbackSessionAtom = + Atom(name: '_PlayerController.playbackSession', context: context); + + @override + int get playbackSession { + _$playbackSessionAtom.reportRead(); + return super.playbackSession; + } + + @override + set playbackSession(int value) { + _$playbackSessionAtom.reportWrite(value, super.playbackSession, () { + super.playbackSession = value; + }); + } + late final _$_PlayerControllerActionController = ActionController(name: '_PlayerController', context: context); @@ -620,7 +636,8 @@ playerAudioParams: ${playerAudioParams}, playerPlaylist: ${playerPlaylist}, playerAudioTracks: ${playerAudioTracks}, playerVideoTracks: ${playerVideoTracks}, -playerAudioBitrate: ${playerAudioBitrate} +playerAudioBitrate: ${playerAudioBitrate}, +playbackSession: ${playbackSession} '''; } } diff --git a/lib/pages/player/player_item.dart b/lib/pages/player/player_item.dart index 4589d2a3b..b7133f60a 100644 --- a/lib/pages/player/player_item.dart +++ b/lib/pages/player/player_item.dart @@ -25,8 +25,12 @@ import 'package:kazumi/pages/collect/collect_controller.dart'; import 'package:hive_ce/hive.dart'; import 'package:kazumi/utils/storage.dart'; import 'package:kazumi/request/apis/danmaku_api.dart'; +import 'package:kazumi/request/apis/bangumi_api.dart'; +import 'package:kazumi/modules/bangumi/bangumi_item.dart'; +import 'package:kazumi/modules/bangumi/episode_item.dart'; import 'package:kazumi/modules/danmaku/danmaku_search_response.dart'; import 'package:kazumi/modules/danmaku/danmaku_episode_response.dart'; +import 'package:kazumi/services/local_video_picker_service.dart'; import 'package:kazumi/pages/player/player_item_surface.dart'; import 'package:mobx/mobx.dart' as mobx; import 'package:kazumi/pages/my/my_controller.dart'; @@ -89,7 +93,6 @@ class _PlayerItemState extends State late bool webDavEnableHistory; // 弹幕 - final _danmuKey = GlobalKey(); late bool _border; late double _opacity; late double _fontSize; @@ -305,7 +308,10 @@ class _PlayerItemState extends State //上一集下一集动作 Future handlePreNextEpisode(String direction) async { - if (videoPageController.loading) return; + if (videoPageController.loading || + !videoPageController.canShowBangumiPanel) { + return; + } final currentRoad = videoPageController.currentRoad; final episodes = videoPageController.roadList[currentRoad].data; int targetEpisode; @@ -426,6 +432,9 @@ class _PlayerItemState extends State } void _handleHove() { + if (!mounted) { + return; + } if (!playerController.showVideoController) { displayVideoController(); } @@ -818,6 +827,10 @@ class _PlayerItemState extends State Timer getPlayerTimer() { return Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } playerController.syncPlaybackState(); unawaited(_updateAndroidPIPActions()); _syncAudioServiceState(); @@ -887,21 +900,29 @@ class _PlayerItemState extends State }); } // 历史记录相关 - if (playerController.playerPlaying && - !videoPageController.loading && - !videoPageController.isOfflineMode) { - final pluginName = videoPageController.isOfflineMode - ? videoPageController.offlinePluginName - : videoPageController.currentPlugin.name; - historyController.updateHistory( - videoPageController.actualEpisodeNumber, - videoPageController.currentRoad, - pluginName, - videoPageController.bangumiItem, - playerController.playerPosition, - videoPageController.src, - videoPageController.roadList[videoPageController.currentRoad] - .identifier[videoPageController.currentEpisode - 1]); + if (playerController.playerPlaying && !videoPageController.loading) { + if (!WebDav().isHistorySyncing) { + final pluginName = videoPageController.isOfflineMode + ? videoPageController.offlinePluginName + : videoPageController.currentPlugin.name; + final episodeTitle = videoPageController + .roadList[videoPageController.currentRoad] + .identifier[videoPageController.currentEpisode - 1]; + historyController.updateHistory( + videoPageController.actualEpisodeNumber, + videoPageController.currentRoad, + pluginName, + videoPageController.bangumiItem, + playerController.playerPosition, + videoPageController.src, + episodeTitle, + localPath: videoPageController.isOfflineMode + ? (videoPageController.offlineVideoPath ?? '') + : '', + episodeTitle: episodeTitle, + sourceType: videoPageController.sourceType, + localVideoContext: videoPageController.localVideoContext); + } } // 自动播放下一集 if (playerController.completed && @@ -1026,7 +1047,9 @@ class _PlayerItemState extends State TextButton( onPressed: () { KazumiDialog.dismiss(); - widget.keyboardFocus.requestFocus(); + if (mounted) { + widget.keyboardFocus.requestFocus(); + } }, child: Text( '取消', @@ -1047,6 +1070,341 @@ class _PlayerItemState extends State ); } + void showLocalBangumiBindSheet() { + final localPath = videoPageController.offlineVideoPath; + if (localPath == null || + localPath.isEmpty || + !File(localPath).existsSync()) { + KazumiDialog.showToast(message: '本地文件不存在或已移动'); + return; + } + + BangumiItem? selectedBangumi; + var results = []; + var searching = false; + var episodeLoading = false; + var episodeLoadFailed = false; + var bindingPlayback = false; + var episodeList = []; + int? selectedEpisode; + final searchTextController = TextEditingController(); + final episodeTextController = TextEditingController( + text: videoPageController.actualEpisodeNumber.toString()); + + widget.keyboardFocus.unfocus(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + Future searchBangumi() async { + final keyword = searchTextController.text.trim(); + if (keyword.isEmpty) { + return; + } + setModalState(() { + searching = true; + }); + try { + final value = await BangumiApi.bangumiSearch(keyword); + if (!context.mounted) { + return; + } + setModalState(() { + results = value; + searching = false; + }); + if (value.isEmpty) { + KazumiDialog.showToast(message: '未找到匹配结果'); + } + } catch (e) { + if (!context.mounted) { + return; + } + setModalState(() { + searching = false; + }); + KazumiDialog.showToast(message: '搜索失败'); + } + } + + Future bindAndReload() async { + if (selectedBangumi == null || bindingPlayback) { + return; + } + setModalState(() { + bindingPlayback = true; + }); + final episode = selectedEpisode ?? + int.tryParse(episodeTextController.text.trim()) ?? + 1; + final offset = playerController.playerPosition.inSeconds; + videoPageController.initForLocalFilePlayback( + context: (videoPageController.localVideoContext ?? + LocalVideoPickerService().buildContext(localPath)) + .copyWith( + title: videoPageController + .roadList[videoPageController.currentRoad] + .identifier[videoPageController.currentEpisode - 1], + ), + boundBangumiItem: selectedBangumi, + episodeNumber: episode < 1 ? 1 : episode, + ); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + await Future.delayed(Duration.zero); + if (!mounted) { + return; + } + await widget.changeEpisode(1, currentRoad: 0, offset: offset); + if (!mounted) { + return; + } + KazumiDialog.showToast(message: '绑定成功'); + } + + Future selectBangumi(BangumiItem item) async { + setModalState(() { + selectedBangumi = item; + selectedEpisode = null; + results = []; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = true; + }); + try { + final value = await BangumiApi.getBangumiEpisodesByID(item.id); + if (!context.mounted) { + return; + } + setModalState(() { + episodeList = value + .where((episode) => episode.type == 0) + .where((episode) => episode.episode > 0) + .toList(); + episodeLoading = false; + episodeLoadFailed = episodeList.isEmpty; + }); + if (episodeList.isEmpty) { + KazumiDialog.showToast(message: '未获取到集数,请手动输入'); + } + } catch (e) { + if (!context.mounted) { + return; + } + setModalState(() { + episodeList = []; + episodeLoading = false; + episodeLoadFailed = true; + }); + KazumiDialog.showToast(message: '未获取到集数,请手动输入'); + } + } + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('绑定番剧', + style: Theme.of(context).textTheme.titleMedium), + ), + IconButton( + tooltip: '关闭', + onPressed: bindingPlayback + ? null + : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: searchTextController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + labelText: 'Bangumi 搜索', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: '清除', + onPressed: () { + setModalState(() { + searchTextController.clear(); + selectedBangumi = null; + selectedEpisode = null; + results = []; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = false; + }); + }, + icon: const Icon(Icons.clear), + ), + ), + onChanged: (_) { + if (selectedBangumi == null) { + return; + } + setModalState(() { + selectedBangumi = null; + selectedEpisode = null; + episodeList = []; + episodeLoadFailed = false; + episodeLoading = false; + }); + }, + onSubmitted: (_) => searchBangumi(), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: searching ? null : searchBangumi, + icon: searching + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.search), + ), + ], + ), + if (selectedBangumi != null) ...[ + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.check_circle_outline), + title: Text(selectedBangumi!.nameCn.isNotEmpty + ? selectedBangumi!.nameCn + : selectedBangumi!.name), + subtitle: Text( + selectedEpisode == null && !episodeLoadFailed + ? '已选择绑定条目' + : '已选择绑定条目 · 第${selectedEpisode ?? int.tryParse(episodeTextController.text.trim()) ?? 1}集', + ), + ), + const SizedBox(height: 12), + if (episodeLoading) + const Center(child: CircularProgressIndicator()) + else if (episodeList.isNotEmpty) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 216), + child: LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = (constraints.maxWidth / 72) + .floor() + .clamp(4, 8) + .toInt(); + return GridView.builder( + shrinkWrap: true, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 48, + ), + itemCount: episodeList.length, + itemBuilder: (context, index) { + final episode = + episodeList[index].episode.toInt(); + final selected = selectedEpisode == episode; + return selected + ? FilledButton.tonal( + onPressed: () {}, + child: Text(episode.toString()), + ) + : OutlinedButton( + onPressed: () { + setModalState(() { + selectedEpisode = episode; + episodeTextController.text = + episode.toString(); + }); + }, + child: Text(episode.toString()), + ); + }, + ); + }, + ), + ) + else if (episodeLoadFailed) + SizedBox( + width: 120, + child: TextField( + controller: episodeTextController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '集数', + border: OutlineInputBorder(), + ), + ), + ), + ], + if (results.isNotEmpty) ...[ + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: ListView.builder( + shrinkWrap: true, + itemCount: results.length, + itemBuilder: (context, index) { + final item = results[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.nameCn.isNotEmpty + ? item.nameCn + : item.name), + subtitle: Text(item.airDate), + selected: selectedBangumi?.id == item.id, + onTap: () { + selectBangumi(item); + }, + ); + }, + ), + ), + ], + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: bindingPlayback || + selectedBangumi == null || + (selectedEpisode == null && !episodeLoadFailed) + ? null + : bindAndReload, + child: const Text('绑定并继续播放'), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + Widget get videoInfoBody { return Observer(builder: (context) { return ListView( @@ -1253,7 +1611,7 @@ class _PlayerItemState extends State border: OutlineInputBorder(), ), isExpanded: true, - value: selectedSyncPlayEndPoint, + initialValue: selectedSyncPlayEndPoint, items: syncPlayEndPoints.map((String value) { return DropdownMenuItem( value: value, @@ -1596,310 +1954,361 @@ class _PlayerItemState extends State return ClipRect( child: Container( color: Colors.black, - child: MouseRegion( - cursor: (videoPageController.isFullscreen && - !playerController.showVideoController) - ? SystemMouseCursors.none - : SystemMouseCursors.basic, - onHover: (PointerEvent pointerEvent) { - // workaround for android. - // I don't know why, but android tap event will trigger onHover event. - if (Utils.isDesktop()) { - if (pointerEvent.position.dy > 50 && - pointerEvent.position.dy < - MediaQuery.of(context).size.height - 70) { - _handleHove(); - } else { - if (!playerController.showVideoController) { - animationController?.forward(); - playerController.showVideoController = true; + child: TooltipVisibility( + visible: false, + child: MouseRegion( + cursor: (videoPageController.isFullscreen && + !playerController.showVideoController) + ? SystemMouseCursors.none + : SystemMouseCursors.basic, + onHover: (PointerEvent pointerEvent) { + // workaround for android. + // I don't know why, but android tap event will trigger onHover event. + if (Utils.isDesktop()) { + if (pointerEvent.position.dy > 50 && + pointerEvent.position.dy < + MediaQuery.of(context).size.height - 70) { + _handleHove(); + } else { + if (!playerController.showVideoController) { + animationController?.forward(); + playerController.showVideoController = true; + } } } - } - }, - child: Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - _handleMouseScroller(); - final scrollDelta = pointerSignal.scrollDelta; - final double volume = - playerController.volume - scrollDelta.dy / 60; - playerController.setVolume(volume); - } }, - child: SizedBox( - height: videoPageController.isFullscreen || - videoPageController.isPip - ? (MediaQuery.of(context).size.height) - : (MediaQuery.of(context).size.width * 9.0 / (16.0)), - width: MediaQuery.of(context).size.width, - child: Stack(alignment: Alignment.center, children: [ - Center( - child: Focus( - // workaround for #461 - // I don't know why, but the focus node will break popscope. - focusNode: widget.keyboardFocus, - autofocus: true, - onKeyEvent: (focusNode, KeyEvent event) { - bool handled = false; - final keyLabel = - event.logicalKey.keyLabel.isNotEmpty - ? event.logicalKey.keyLabel - : event.logicalKey.debugName ?? ''; - if (event is KeyDownEvent) { - handled = handleShortcutDown(keyLabel); - } else if (event is KeyRepeatEvent) { - handled = - handleShortcutLongPress(keyLabel, "Repeat"); - } else if (event is KeyUpEvent) { - handled = - handleShortcutLongPress(keyLabel, "Up"); - } - return handled - ? KeyEventResult.handled - : KeyEventResult.ignored; - }, - child: const PlayerItemSurface())), - (playerController.isBuffering || - videoPageController.loading) - ? const Positioned.fill( + child: Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + _handleMouseScroller(); + final scrollDelta = pointerSignal.scrollDelta; + final double volume = + playerController.volume - scrollDelta.dy / 60; + playerController.setVolume(volume); + } + }, + child: LayoutBuilder( + builder: (context, constraints) { + final fallbackSize = MediaQuery.sizeOf(context); + final width = (constraints.maxWidth.isFinite + ? constraints.maxWidth + : fallbackSize.width) + .clamp(0.0, fallbackSize.width); + final height = (constraints.maxHeight.isFinite + ? constraints.maxHeight + : fallbackSize.height) + .clamp(0.0, fallbackSize.height); + if (width < 1 || height < 1) { + return const SizedBox.shrink(); + } + return SizedBox( + height: height, + width: width, + child: Stack(alignment: Alignment.center, children: [ + Positioned.fill( child: Center( - child: CircularProgressIndicator(), + child: Focus( + // workaround for #461 + // I don't know why, but the focus node will break popscope. + focusNode: widget.keyboardFocus, + autofocus: true, + onKeyEvent: (focusNode, KeyEvent event) { + if (!mounted) { + return KeyEventResult.ignored; + } + bool handled = false; + final keyLabel = + event.logicalKey.keyLabel.isNotEmpty + ? event.logicalKey.keyLabel + : event.logicalKey.debugName ?? ''; + if (event is KeyDownEvent) { + handled = handleShortcutDown(keyLabel); + } else if (event is KeyRepeatEvent) { + handled = handleShortcutLongPress( + keyLabel, "Repeat"); + } else if (event is KeyUpEvent) { + handled = handleShortcutLongPress( + keyLabel, "Up"); + } + return handled + ? KeyEventResult.handled + : KeyEventResult.ignored; + }, + child: const PlayerItemSurface()), ), - ) - : Container(), - GestureDetector( - onTap: () { - _handleTap(); - }, - onDoubleTap: (playerController.lockPanel) - ? null - : () { - _handleDoubleTap(); - }, - onLongPressStart: (_) { - if (playerController.lockPanel) { - return; - } - setState(() { - playerController.showPlaySpeed = true; - }); - lastPlayerSpeed = playerController.playerSpeed; - setPlaybackSpeed(2.0); - }, - onLongPressEnd: (_) { - if (playerController.lockPanel) { - return; - } - setState(() { - playerController.showPlaySpeed = false; - }); - setPlaybackSpeed(lastPlayerSpeed); - }, - child: Container( - color: Colors.transparent, - width: double.infinity, - height: double.infinity, - ), - ), - // 弹幕面板 - Positioned( - top: 0, - left: 0, - right: 0, - height: videoPageController.isFullscreen || - videoPageController.isPip - ? MediaQuery.sizeOf(context).height - : (MediaQuery.sizeOf(context).width * 9 / 16), - child: DanmakuScreen( - key: _danmuKey, - createdController: (DanmakuController e) { - playerController.danmakuController = e; - WidgetsBinding.instance.addPostFrameCallback((_) { - playerController.updateDanmakuSpeed(); - }); - }, - option: DanmakuOption( - hideTop: _hideTop, - hideScroll: _hideScroll, - hideBottom: _hideBottom, - area: _danmakuArea, - opacity: _opacity, - fontSize: _fontSize, - duration: - _danmakuDuration / playerController.playerSpeed, - lineHeight: _danmakuLineHeight, - strokeWidth: _border ? _danmakuBorderSize : 0.0, - fontWeight: _danmakuFontWeight, - massiveMode: _massiveMode, - fontFamily: _danmakuUseSystemFont - ? null - : customAppFontFamily, - ), - ), - ), - // 播放器控制面板 - (needFullPanel(context)) - ? PlayerItemPanel( - onBackPressed: widget.onBackPressed, - setPlaybackSpeed: setPlaybackSpeed, - showDanmakuSwitch: showDanmakuSwitch, - changeEpisode: widget.changeEpisode, - openMenu: widget.openMenu, - handleFullscreen: handleFullscreen, - handleProgressBarDragStart: - handleProgressBarDragStart, - handleProgressBarDragEnd: handleProgressBarDragEnd, - handleSuperResolutionChange: - handleSuperResolutionChange, - handlePreNextEpisode: handlePreNextEpisode, - animationController: animationController!, - keyboardFocus: widget.keyboardFocus, - sendDanmaku: widget.sendDanmaku, - startHideTimer: startHideTimer, - cancelHideTimer: cancelHideTimer, - handleDanmaku: handleDanmaku, - showVideoInfo: showVideoInfo, - showSyncPlayRoomCreateDialog: - showSyncPlayRoomCreateDialog, - showSyncPlayEndPointSwitchDialog: - showSyncPlayEndPointSwitchDialog, - showDanmakuDestinationPickerAndSend: - widget.showDanmakuDestinationPickerAndSend, - pauseForTimedShutdown: widget.pauseForTimedShutdown, - disableAnimations: widget.disableAnimations, - handleScreenShot: handleScreenshot, - skipOP: skipOP, - ) - : SmallestPlayerItemPanel( - onBackPressed: widget.onBackPressed, - setPlaybackSpeed: setPlaybackSpeed, - showDanmakuSwitch: showDanmakuSwitch, - handleFullscreen: handleFullscreen, - handleProgressBarDragStart: - handleProgressBarDragStart, - handleProgressBarDragEnd: handleProgressBarDragEnd, - handleSuperResolutionChange: - handleSuperResolutionChange, - animationController: animationController!, - keyboardFocus: widget.keyboardFocus, - handleHove: _handleHove, - startHideTimer: startHideTimer, - cancelHideTimer: cancelHideTimer, - handleDanmaku: handleDanmaku, - showVideoInfo: showVideoInfo, - showSyncPlayRoomCreateDialog: - showSyncPlayRoomCreateDialog, - showSyncPlayEndPointSwitchDialog: - showSyncPlayEndPointSwitchDialog, - pauseForTimedShutdown: widget.pauseForTimedShutdown, - disableAnimations: widget.disableAnimations, - skipOP: skipOP, ), - // 播放器手势控制 - Positioned.fill( - left: 16, - top: 25, - right: 15, - bottom: 15, - child: (Utils.isDesktop() || playerController.lockPanel) - ? Container() - : GestureDetector( - onHorizontalDragStart: (_) { - if (!playerController.showVideoController) { - animationController?.forward(); - } - playerController.canHidePlayerPanel = false; - }, - onHorizontalDragUpdate: - (DragUpdateDetails details) { - playerController.showSeekTime = true; - playerTimer?.cancel(); - playerController.pause(enableSync: false); - final double scale = - 180000 / MediaQuery.sizeOf(context).width; - int ms = (playerController - .currentPosition.inMilliseconds + - (details.delta.dx * scale).round()) - .clamp( - 0, - playerController - .duration.inMilliseconds); - playerController.currentPosition = - Duration(milliseconds: ms); - }, - onHorizontalDragEnd: (_) { - playerController.play(enableSync: false); - playerController - .seek(playerController.currentPosition); - playerController.canHidePlayerPanel = true; - if (!playerController.showVideoController) { - animationController?.reverse(); - } else { - hideTimer?.cancel(); - startHideTimer(); - } - playerTimer?.cancel(); - playerTimer = getPlayerTimer(); - playerController.showSeekTime = false; + ((playerController.isBuffering && + playerController.playerWidth <= 0) || + videoPageController.loading) + ? const Positioned.fill( + child: Center( + child: CircularProgressIndicator(), + ), + ) + : Container(), + Positioned.fill( + child: GestureDetector( + onTap: () { + _handleTap(); }, - onVerticalDragUpdate: - (DragUpdateDetails details) async { - if (!brightnessVolumeGesture) { + onDoubleTap: (playerController.lockPanel) + ? null + : () { + _handleDoubleTap(); + }, + onLongPressStart: (_) { + if (playerController.lockPanel) { return; } - final double totalWidth = - MediaQuery.sizeOf(context).width; - final double totalHeight = - MediaQuery.sizeOf(context).height; - final double tapPosition = - details.localPosition.dx; - final double sectionWidth = totalWidth / 2; - final double delta = details.delta.dy; - - if (tapPosition < sectionWidth) { - // 左边区域 - playerController.brightnessSeeking = true; - playerController.showBrightness = true; - final double level = (totalHeight) * 2; - final double brightness = - playerController.brightness - - delta / level; - final double result = - brightness.clamp(0.0, 1.0); - setBrightness(result); - playerController.brightness = result; - } else { - // 右边区域 - playerController.volumeSeeking = true; - playerController.showVolume = true; - final double level = (totalHeight) * 0.03; - final double volume = - playerController.volume - delta / level; - playerController.setVolume(volume); - } + setState(() { + playerController.showPlaySpeed = true; + }); + lastPlayerSpeed = playerController.playerSpeed; + setPlaybackSpeed(2.0); }, - onVerticalDragEnd: (_) { - if (!brightnessVolumeGesture) { + onLongPressEnd: (_) { + if (playerController.lockPanel) { return; } - if (playerController.volumeSeeking) { - playerController.volumeSeeking = false; - Future.delayed(const Duration(seconds: 1), - () { - FlutterVolumeController.updateShowSystemUI( - true); - }); - } - if (playerController.brightnessSeeking) { - playerController.brightnessSeeking = false; - } - playerController.showVolume = false; - playerController.showBrightness = false; + setState(() { + playerController.showPlaySpeed = false; + }); + setPlaybackSpeed(lastPlayerSpeed); }, + child: Container( + color: Colors.transparent, + width: double.infinity, + height: double.infinity, + ), ), - ), - ]), + ), + // 弹幕面板 + Positioned( + top: 0, + left: 0, + right: 0, + height: height, + child: DanmakuScreen( + createdController: (DanmakuController e) { + playerController.danmakuController = e; + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!mounted) { + return; + } + playerController.updateDanmakuSpeed(); + }); + }, + option: DanmakuOption( + hideTop: _hideTop, + hideScroll: _hideScroll, + hideBottom: _hideBottom, + area: _danmakuArea, + opacity: _opacity, + fontSize: _fontSize, + duration: _danmakuDuration / + playerController.playerSpeed, + lineHeight: _danmakuLineHeight, + strokeWidth: _border ? _danmakuBorderSize : 0.0, + fontWeight: _danmakuFontWeight, + massiveMode: _massiveMode, + fontFamily: _danmakuUseSystemFont + ? null + : customAppFontFamily, + ), + ), + ), + // 播放器控制面板 + (needFullPanel(context)) + ? Positioned.fill( + child: PlayerItemPanel( + onBackPressed: widget.onBackPressed, + setPlaybackSpeed: setPlaybackSpeed, + showDanmakuSwitch: showDanmakuSwitch, + changeEpisode: widget.changeEpisode, + openMenu: widget.openMenu, + handleFullscreen: handleFullscreen, + handleProgressBarDragStart: + handleProgressBarDragStart, + handleProgressBarDragEnd: + handleProgressBarDragEnd, + handleSuperResolutionChange: + handleSuperResolutionChange, + handlePreNextEpisode: handlePreNextEpisode, + animationController: animationController!, + keyboardFocus: widget.keyboardFocus, + sendDanmaku: widget.sendDanmaku, + startHideTimer: startHideTimer, + cancelHideTimer: cancelHideTimer, + handleDanmaku: handleDanmaku, + showVideoInfo: showVideoInfo, + showSyncPlayRoomCreateDialog: + showSyncPlayRoomCreateDialog, + showSyncPlayEndPointSwitchDialog: + showSyncPlayEndPointSwitchDialog, + showDanmakuDestinationPickerAndSend: widget + .showDanmakuDestinationPickerAndSend, + pauseForTimedShutdown: + widget.pauseForTimedShutdown, + showLocalBangumiBindSheet: + showLocalBangumiBindSheet, + disableAnimations: widget.disableAnimations, + handleScreenShot: handleScreenshot, + skipOP: skipOP, + ), + ) + : Positioned.fill( + child: SmallestPlayerItemPanel( + onBackPressed: widget.onBackPressed, + setPlaybackSpeed: setPlaybackSpeed, + showDanmakuSwitch: showDanmakuSwitch, + handleFullscreen: handleFullscreen, + handleProgressBarDragStart: + handleProgressBarDragStart, + handleProgressBarDragEnd: + handleProgressBarDragEnd, + handleSuperResolutionChange: + handleSuperResolutionChange, + animationController: animationController!, + keyboardFocus: widget.keyboardFocus, + handleHove: _handleHove, + startHideTimer: startHideTimer, + cancelHideTimer: cancelHideTimer, + handleDanmaku: handleDanmaku, + showVideoInfo: showVideoInfo, + showSyncPlayRoomCreateDialog: + showSyncPlayRoomCreateDialog, + showSyncPlayEndPointSwitchDialog: + showSyncPlayEndPointSwitchDialog, + pauseForTimedShutdown: + widget.pauseForTimedShutdown, + showLocalBangumiBindSheet: + showLocalBangumiBindSheet, + disableAnimations: widget.disableAnimations, + skipOP: skipOP, + ), + ), + // 播放器手势控制 + Positioned.fill( + left: 16, + top: 25, + right: 15, + bottom: 15, + child: (Utils.isDesktop() || + playerController.lockPanel) + ? Container() + : GestureDetector( + onHorizontalDragStart: (_) { + if (!playerController + .showVideoController) { + animationController?.forward(); + } + playerController.canHidePlayerPanel = + false; + }, + onHorizontalDragUpdate: + (DragUpdateDetails details) { + playerController.showSeekTime = true; + playerTimer?.cancel(); + playerController.pause(enableSync: false); + final double scale = 180000 / + MediaQuery.sizeOf(context).width; + int ms = (playerController.currentPosition + .inMilliseconds + + (details.delta.dx * scale) + .round()) + .clamp( + 0, + playerController + .duration.inMilliseconds); + playerController.currentPosition = + Duration(milliseconds: ms); + }, + onHorizontalDragEnd: (_) { + playerController.play(enableSync: false); + playerController.seek( + playerController.currentPosition); + playerController.canHidePlayerPanel = + true; + if (!playerController + .showVideoController) { + animationController?.reverse(); + } else { + hideTimer?.cancel(); + startHideTimer(); + } + playerTimer?.cancel(); + playerTimer = getPlayerTimer(); + playerController.showSeekTime = false; + }, + onVerticalDragUpdate: + (DragUpdateDetails details) async { + if (!brightnessVolumeGesture) { + return; + } + final double totalWidth = + MediaQuery.sizeOf(context).width; + final double totalHeight = + MediaQuery.sizeOf(context).height; + final double tapPosition = + details.localPosition.dx; + final double sectionWidth = + totalWidth / 2; + final double delta = details.delta.dy; + + if (tapPosition < sectionWidth) { + // 左边区域 + playerController.brightnessSeeking = + true; + playerController.showBrightness = true; + final double level = (totalHeight) * 2; + final double brightness = + playerController.brightness - + delta / level; + final double result = + brightness.clamp(0.0, 1.0); + setBrightness(result); + playerController.brightness = result; + } else { + // 右边区域 + playerController.volumeSeeking = true; + playerController.showVolume = true; + final double level = + (totalHeight) * 0.03; + final double volume = + playerController.volume - + delta / level; + playerController.setVolume(volume); + } + }, + onVerticalDragEnd: (_) { + if (!brightnessVolumeGesture) { + return; + } + if (playerController.volumeSeeking) { + playerController.volumeSeeking = false; + Future.delayed( + const Duration(seconds: 1), () { + if (!mounted) { + return; + } + FlutterVolumeController + .updateShowSystemUI(true); + }); + } + if (playerController.brightnessSeeking) { + playerController.brightnessSeeking = + false; + } + playerController.showVolume = false; + playerController.showBrightness = false; + }, + ), + ), + ]), + ); + }, + ), ), ), ), diff --git a/lib/pages/player/player_item_panel.dart b/lib/pages/player/player_item_panel.dart index 868a6296c..c6cb2b2cf 100644 --- a/lib/pages/player/player_item_panel.dart +++ b/lib/pages/player/player_item_panel.dart @@ -47,6 +47,7 @@ class PlayerItemPanel extends StatefulWidget { required this.showSyncPlayEndPointSwitchDialog, required this.showDanmakuDestinationPickerAndSend, required this.pauseForTimedShutdown, + required this.showLocalBangumiBindSheet, this.disableAnimations = false, }); @@ -73,6 +74,7 @@ class PlayerItemPanel extends StatefulWidget { final void Function() showSyncPlayEndPointSwitchDialog; final void Function(String) showDanmakuDestinationPickerAndSend; final VoidCallback pauseForTimedShutdown; + final VoidCallback showLocalBangumiBindSheet; final bool disableAnimations; @override @@ -88,8 +90,7 @@ class _PlayerItemPanelState extends State { final VideoPageController videoPageController = Modular.get(); final PlayerController playerController = Modular.get(); - final DownloadController downloadController = - Modular.get(); + final DownloadController downloadController = Modular.get(); final TextEditingController textController = TextEditingController(); final FocusNode textFieldFocus = FocusNode(); // SVG Caches @@ -136,8 +137,7 @@ class _PlayerItemPanelState extends State { TextButton( onPressed: () { textFieldFocus.unfocus(); - widget - .showDanmakuDestinationPickerAndSend(textController.text); + widget.showDanmakuDestinationPickerAndSend(textController.text); textController.clear(); }, style: TextButton.styleFrom( @@ -148,8 +148,7 @@ class _PlayerItemPanelState extends State { ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).disabledColor, shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(Utils.isDesktop() ? 8 : 20), + borderRadius: BorderRadius.circular(Utils.isDesktop() ? 8 : 20), ), ), child: const Text('发送'), @@ -175,7 +174,9 @@ class _PlayerItemPanelState extends State { widget.startHideTimer(); playerController.canHidePlayerPanel = true; textFieldFocus.unfocus(); - widget.keyboardFocus.requestFocus(); + if (mounted) { + widget.keyboardFocus.requestFocus(); + } }, ), ); @@ -279,6 +280,8 @@ class _PlayerItemPanelState extends State { }); } + + @override void initState() { super.initState(); @@ -366,7 +369,9 @@ class _PlayerItemPanelState extends State { }, tooltip: playerController.danmakuLoading ? '弹幕加载中...' - : (playerController.danmakuOn ? '关闭弹幕' : '打开弹幕'), + : (playerController.danmakuOn + ? '关闭弹幕' + : '打开弹幕'), ); } @@ -391,16 +396,17 @@ class _PlayerItemPanelState extends State { @override Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - AnimatedPositioned( - duration: const Duration(seconds: 1), - top: 0, - left: 0, - right: 0, - child: Observer(builder: (context) { - return Visibility( + return Observer(builder: (context) { + return Stack( + alignment: Alignment.center, + children: [ + //顶部渐变区域 + AnimatedPositioned( + duration: const Duration(seconds: 1), + top: 0, + left: 0, + right: 0, + child: Visibility( visible: !playerController.lockPanel && (widget.disableAnimations ? playerController.showVideoController @@ -435,16 +441,16 @@ class _PlayerItemPanelState extends State { ), ), ), - ); - }), - ), - AnimatedPositioned( - duration: const Duration(seconds: 1), - bottom: 0, - left: 0, - right: 0, - child: Observer(builder: (context) { - return Visibility( + ), + ), + + //底部渐变区域 + AnimatedPositioned( + duration: const Duration(seconds: 1), + bottom: 0, + left: 0, + right: 0, + child: Visibility( visible: !playerController.lockPanel && (widget.disableAnimations ? playerController.showVideoController @@ -479,13 +485,12 @@ class _PlayerItemPanelState extends State { ), ), ), - ); - }), - ), - Positioned( - top: 25, - child: Observer(builder: (context) { - return playerController.showSeekTime + ), + ), + // 顶部进度条 + Positioned( + top: 25, + child: playerController.showSeekTime ? Wrap( alignment: WrapAlignment.center, children: [ @@ -493,7 +498,7 @@ class _PlayerItemPanelState extends State { padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8.0), // 圆角 ), child: Text( playerController.currentPosition.compareTo( @@ -508,12 +513,11 @@ class _PlayerItemPanelState extends State { ), ], ) - : Container(); - })), - Positioned( - top: 25, - child: Observer(builder: (context) { - return playerController.showPlaySpeed + : Container()), + // 顶部播放速度条 + Positioned( + top: 25, + child: playerController.showPlaySpeed ? Wrap( alignment: WrapAlignment.center, children: [ @@ -521,7 +525,7 @@ class _PlayerItemPanelState extends State { padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8.0), // 圆角 ), child: const Row( children: [ @@ -537,12 +541,11 @@ class _PlayerItemPanelState extends State { ), ], ) - : Container(); - })), - Positioned( - top: 25, - child: Observer(builder: (context) { - return playerController.showBrightness + : Container()), + // 亮度条 + Positioned( + top: 25, + child: playerController.showBrightness ? Wrap( alignment: WrapAlignment.center, children: [ @@ -550,7 +553,7 @@ class _PlayerItemPanelState extends State { padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8.0), // 圆角 ), child: Row( children: [ @@ -566,12 +569,11 @@ class _PlayerItemPanelState extends State { )), ], ) - : Container(); - })), - Positioned( - top: 25, - child: Observer(builder: (context) { - return playerController.showVolume + : Container()), + // 音量条 + Positioned( + top: 25, + child: playerController.showVolume ? Wrap( alignment: WrapAlignment.center, children: [ @@ -579,7 +581,7 @@ class _PlayerItemPanelState extends State { padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8.0), // 圆角 ), child: Row( children: [ @@ -595,16 +597,15 @@ class _PlayerItemPanelState extends State { )), ], ) - : Container(); - })), - (Utils.isDesktop() || !videoPageController.isFullscreen) - ? Container() - : Positioned( - right: 0, - top: 0, - bottom: 0, - child: Observer(builder: (context) { - return Visibility( + : Container()), + // 右侧锁定按钮 + (Utils.isDesktop() || !videoPageController.isFullscreen) + ? Container() + : Positioned( + right: 0, + top: 0, + bottom: 0, + child: Visibility( visible: widget.disableAnimations ? playerController.showVideoController : true, @@ -613,15 +614,14 @@ class _PlayerItemPanelState extends State { : SlideTransition( position: leftOffsetAnimation, child: leftControlWidget), - ); - }), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Observer(builder: (context) { - return Visibility( + ), + ), + // 自定义顶部组件 + Positioned( + top: 0, + left: 0, + right: 0, + child: Visibility( visible: !playerController.lockPanel && (widget.disableAnimations ? playerController.showVideoController @@ -630,15 +630,14 @@ class _PlayerItemPanelState extends State { ? topControlWidget : SlideTransition( position: topOffsetAnimation, child: topControlWidget), - ); - }), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Observer(builder: (context) { - return Visibility( + ), + ), + // 自定义播放器底部组件 + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Visibility( visible: !playerController.lockPanel && (widget.disableAnimations ? playerController.showVideoController @@ -648,374 +647,272 @@ class _PlayerItemPanelState extends State { : SlideTransition( position: bottomOffsetAnimation, child: bottomControlWidget), - ); - }), - ), - ], - ); + ), + ), + ], + ); + }); } Widget get bottomControlWidget { - return Observer(builder: (context) { - return SafeArea( - top: false, - bottom: videoPageController.isFullscreen, - left: videoPageController.isFullscreen, - right: videoPageController.isFullscreen, - child: MouseRegion( - cursor: (videoPageController.isFullscreen && - !playerController.showVideoController) - ? SystemMouseCursors.none - : SystemMouseCursors.basic, - onEnter: (_) { - widget.cancelHideTimer(); - }, - onExit: (_) { - widget.cancelHideTimer(); - widget.startHideTimer(); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!Utils.isDesktop() && !Utils.isTablet()) - Container( - padding: const EdgeInsets.only(left: 10.0, bottom: 10), - child: Text( - "${Utils.durationToString(playerController.currentPosition)} / ${Utils.durationToString(playerController.duration)}", - style: const TextStyle( + return Observer( + builder: (context) { + return SafeArea( + top: false, + bottom: videoPageController.isFullscreen, + left: videoPageController.isFullscreen, + right: videoPageController.isFullscreen, + child: MouseRegion( + cursor: (videoPageController.isFullscreen && + !playerController.showVideoController) + ? SystemMouseCursors.none + : SystemMouseCursors.basic, + onEnter: (_) { + widget.cancelHideTimer(); + }, + onExit: (_) { + widget.cancelHideTimer(); + widget.startHideTimer(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!Utils.isDesktop() && !Utils.isTablet()) + Container( + padding: const EdgeInsets.only(left: 10.0, bottom: 10), + child: Text( + "${Utils.durationToString(playerController.currentPosition)} / ${Utils.durationToString(playerController.duration)}", + style: const TextStyle( + color: Colors.white, + fontSize: 12.0, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: ProgressBar( + thumbRadius: 8, + thumbGlowRadius: 18, + timeLabelLocation: Utils.isTablet() + ? TimeLabelLocation.sides + : TimeLabelLocation.none, + timeLabelTextStyle: const TextStyle( color: Colors.white, fontSize: 12.0, fontFeatures: [ FontFeature.tabularFigures(), ], ), + progress: playerController.currentPosition, + buffered: playerController.buffer, + total: playerController.duration, + onSeek: (duration) { + playerController.seek(duration); + }, + onDragStart: (details) { + widget.handleProgressBarDragStart(details); + }, + onDragUpdate: (details) => + {playerController.currentPosition = details.timeStamp}, + onDragEnd: () { + widget.handleProgressBarDragEnd(); + }, ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: ProgressBar( - thumbRadius: 8, - thumbGlowRadius: 18, - timeLabelLocation: Utils.isTablet() - ? TimeLabelLocation.sides - : TimeLabelLocation.none, - timeLabelTextStyle: const TextStyle( - color: Colors.white, - fontSize: 12.0, - fontFeatures: [ - FontFeature.tabularFigures(), - ], - ), - progress: playerController.currentPosition, - buffered: playerController.buffer, - total: playerController.duration, - onSeek: (duration) { - playerController.seek(duration); - }, - onDragStart: (details) { - widget.handleProgressBarDragStart(details); - }, - onDragUpdate: (details) => - {playerController.currentPosition = details.timeStamp}, - onDragEnd: () { - widget.handleProgressBarDragEnd(); - }, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - IconButton( - color: Colors.white, - icon: Icon(playerController.playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded), - tooltip: playerController.playing ? '暂停' : '播放', - onPressed: () { - playerController.playOrPause(); - }, - ), - // 更换选集 - if (videoPageController.isFullscreen || - Utils.isTablet() || - Utils.isDesktop()) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ IconButton( color: Colors.white, - icon: const Icon(Icons.skip_next_rounded), - tooltip: '下一集', - onPressed: () => widget.handlePreNextEpisode('next'), - ), - if (Utils.isDesktop()) - Container( - padding: const EdgeInsets.only(left: 10.0), - child: Text( - "${Utils.durationToString(playerController.currentPosition)} / ${Utils.durationToString(playerController.duration)}", - style: const TextStyle( - color: Colors.white, - fontSize: 16.0, - fontFeatures: [ - FontFeature.tabularFigures(), - ], - ), - ), - ), - if (Utils.isDesktop()) - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - bool isSpaceEnough = constraints.maxWidth > 600; - return Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildDanmakuToggleButton(context), - IconButton( - onPressed: () { - widget.keyboardFocus.requestFocus(); - showModalBottomSheet( - isScrollControlled: true, - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context) - .size - .height * - 3 / - 4, - maxWidth: (Utils.isDesktop() || - Utils.isTablet()) - ? MediaQuery.of(context) - .size - .width * - 9 / - 16 - : MediaQuery.of(context) - .size - .width), - clipBehavior: Clip.antiAlias, - context: context, - builder: (context) { - return DanmakuSettingsSheet( - danmakuController: - playerController - .danmakuController, - onUpdateDanmakuSpeed: - playerController - .updateDanmakuSpeed, - ); - }); - }, - color: Colors.white, - icon: cachedDanmakuSettingIcon!, - tooltip: '弹幕设置', - ), - if (isSpaceEnough) danmakuTextField, - ], - ), - ); - }, - ), - ), - if (!Utils.isDesktop()) ...[ - IconButton( - color: Colors.white, - icon: playerController.danmakuOn - ? danmakuOnIcon(context) - : cachedDanmakuOffIcon!, + icon: Icon(playerController.playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded), + tooltip: playerController.playing ? '暂停' : '播放', onPressed: () { - widget.handleDanmaku(); + playerController.playOrPause(); }, - tooltip: playerController.danmakuOn ? '关闭弹幕' : '打开弹幕', ), - if (playerController.danmakuOn) ...[ + // 更换选集 + if (videoPageController.isFullscreen || + Utils.isTablet() || + Utils.isDesktop()) IconButton( - onPressed: () { - showModalBottomSheet( - isScrollControlled: true, - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * - 3 / - 4, - maxWidth: (Utils.isDesktop() || - Utils.isTablet()) - ? MediaQuery.of(context).size.width * - 9 / - 16 - : MediaQuery.of(context).size.width), - clipBehavior: Clip.antiAlias, - context: context, - builder: (context) { - return DanmakuSettingsSheet( - danmakuController: - playerController.danmakuController, - onUpdateDanmakuSpeed: - playerController.updateDanmakuSpeed, - ); - }); - }, color: Colors.white, - icon: cachedDanmakuSettingIcon!, - tooltip: '弹幕设置', + icon: const Icon(Icons.skip_next_rounded), + tooltip: '下一集', + onPressed: () => widget.handlePreNextEpisode('next'), ), - Expanded(child: danmakuTextField), - ], - if (!playerController.danmakuOn) const Spacer(), - ], - // 超分辨率 - MenuAnchor( - consumeOutsideTap: true, - onOpen: () { - widget.cancelHideTimer(); - playerController.canHidePlayerPanel = false; - }, - onClose: () { - widget.cancelHideTimer(); - widget.startHideTimer(); - playerController.canHidePlayerPanel = true; - }, - builder: (BuildContext context, MenuController controller, - Widget? child) { - return TextButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: const Text( - '超分辨率', - style: TextStyle(color: Colors.white), + if (Utils.isDesktop()) + Container( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + "${Utils.durationToString(playerController.currentPosition)} / ${Utils.durationToString(playerController.duration)}", + style: const TextStyle( + color: Colors.white, + fontSize: 16.0, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), ), - ); - }, - menuChildren: List.generate( - 3, - (int index) => MenuItemButton( - onPressed: () => - widget.handleSuperResolutionChange(index + 1), - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - index + 1 == 1 - ? '关闭' - : index + 1 == 2 - ? '效率档' - : '质量档', - style: TextStyle( - color: playerController.superResolutionType == - index + 1 - ? Theme.of(context).colorScheme.primary - : null, + ), + if (Utils.isDesktop()) + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + bool isSpaceEnough = constraints.maxWidth > 600; + return Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (videoPageController.hasBangumiBinding) + _buildDanmakuToggleButton(context), + if (videoPageController.hasBangumiBinding) + IconButton( + onPressed: () { + if (mounted) { + widget.keyboardFocus.requestFocus(); + } + showModalBottomSheet( + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context) + .size + .height * + 3 / + 4, + maxWidth: (Utils.isDesktop() || + Utils.isTablet()) + ? MediaQuery.of(context) + .size + .width * + 9 / + 16 + : MediaQuery.of(context) + .size + .width), + clipBehavior: Clip.antiAlias, + context: context, + builder: (context) { + return DanmakuSettingsSheet( + danmakuController: + playerController + .danmakuController, + onUpdateDanmakuSpeed: + playerController.updateDanmakuSpeed, + ); + }); + }, + color: Colors.white, + icon: cachedDanmakuSettingIcon!, + tooltip: '弹幕设置', + ), + if (videoPageController.hasBangumiBinding && + isSpaceEnough) danmakuTextField, + ], ), - ), - ), + ); + }, ), ), - ), - ), - // 倍速播放 - MenuAnchor( - consumeOutsideTap: true, - onOpen: () { - widget.cancelHideTimer(); - playerController.canHidePlayerPanel = false; - }, - onClose: () { - widget.cancelHideTimer(); - widget.startHideTimer(); - playerController.canHidePlayerPanel = true; - }, - builder: (BuildContext context, MenuController controller, - Widget? child) { - return TextButton( + if (!Utils.isDesktop()) ...[ + if (videoPageController.hasBangumiBinding) IconButton( + color: Colors.white, + icon: playerController.danmakuOn + ? danmakuOnIcon(context) + : cachedDanmakuOffIcon!, onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } + widget.handleDanmaku(); }, - child: Text( - playerController.playerSpeed == 1.0 - ? '倍速' - : '${playerController.playerSpeed}x', - style: const TextStyle(color: Colors.white), - ), - ); - }, - menuChildren: [ - for (final double i - in defaultPlaySpeedList) ...[ - MenuItemButton( - onPressed: () async { - await widget.setPlaybackSpeed(i); + tooltip: + playerController.danmakuOn ? '关闭弹幕' : '打开弹幕', + ), + if (playerController.danmakuOn) ...[ + IconButton( + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * + 3 / + 4, + maxWidth: + (Utils.isDesktop() || Utils.isTablet()) + ? MediaQuery.of(context).size.width * + 9 / + 16 + : MediaQuery.of(context).size.width), + clipBehavior: Clip.antiAlias, + context: context, + builder: (context) { + return DanmakuSettingsSheet( + danmakuController: + playerController.danmakuController, + onUpdateDanmakuSpeed: + playerController.updateDanmakuSpeed, + ); + }); }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '${i}x', - style: TextStyle( - color: i == playerController.playerSpeed - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - ), - ), + color: Colors.white, + icon: cachedDanmakuSettingIcon!, + tooltip: '弹幕设置', ), + Expanded(child: danmakuTextField), ], + if (!playerController.danmakuOn) const Spacer(), ], - ), - MenuAnchor( - consumeOutsideTap: true, - onOpen: () { - widget.cancelHideTimer(); - playerController.canHidePlayerPanel = false; - }, - onClose: () { - widget.cancelHideTimer(); - widget.startHideTimer(); - playerController.canHidePlayerPanel = true; - }, - builder: (BuildContext context, MenuController controller, - Widget? child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon( - Icons.aspect_ratio_rounded, - color: Colors.white, - ), - tooltip: '视频比例', - ); - }, - menuChildren: [ - for (final entry in aspectRatioTypeMap.entries) - MenuItemButton( + // 超分辨率 + MenuAnchor( + consumeOutsideTap: true, + onOpen: () { + widget.cancelHideTimer(); + playerController.canHidePlayerPanel = false; + }, + onClose: () { + widget.cancelHideTimer(); + widget.startHideTimer(); + playerController.canHidePlayerPanel = true; + }, + builder: (BuildContext context, MenuController controller, + Widget? child) { + return TextButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text( + '超分辨率', + style: TextStyle(color: Colors.white), + ), + ); + }, + menuChildren: List.generate( + 3, + (int index) => MenuItemButton( onPressed: () => - playerController.aspectRatioType = entry.key, + widget.handleSuperResolutionChange(index + 1), child: Container( height: 48, constraints: BoxConstraints(minWidth: 112), child: Align( alignment: Alignment.centerLeft, child: Text( - entry.value, + index + 1 == 1 + ? '关闭' + : index + 1 == 2 + ? '效率档' + : '质量档', style: TextStyle( - color: entry.key == - playerController.aspectRatioType + color: playerController.superResolutionType == + index + 1 ? Theme.of(context).colorScheme.primary : null, ), @@ -1023,274 +920,379 @@ class _PlayerItemPanelState extends State { ), ), ), - ], - ), - (!videoPageController.isFullscreen && - !Utils.isTablet() && - !Utils.isDesktop()) - ? Container() - : IconButton( - color: Colors.white, - icon: const Icon(Icons.menu_open_rounded), - tooltip: '选集面板', + ), + ), + // 倍速播放 + MenuAnchor( + consumeOutsideTap: true, + onOpen: () { + widget.cancelHideTimer(); + playerController.canHidePlayerPanel = false; + }, + onClose: () { + widget.cancelHideTimer(); + widget.startHideTimer(); + playerController.canHidePlayerPanel = true; + }, + builder: (BuildContext context, MenuController controller, + Widget? child) { + return TextButton( onPressed: () { - videoPageController.showTabBody = - !videoPageController.showTabBody; - widget.openMenu(); + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } }, - ), - (Utils.isTablet() && - videoPageController.isFullscreen && - MediaQuery.of(context).size.height < - MediaQuery.of(context).size.width) - ? Container() - : IconButton( - color: Colors.white, - icon: Icon(videoPageController.isFullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded), - tooltip: videoPageController.isFullscreen - ? '退出全屏' - : '全屏', + child: Text( + playerController.playerSpeed == 1.0 + ? '倍速' + : '${playerController.playerSpeed}x', + style: const TextStyle(color: Colors.white), + ), + ); + }, + menuChildren: [ + for (final double i + in defaultPlaySpeedList) ...[ + MenuItemButton( + onPressed: () async { + await widget.setPlaybackSpeed(i); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${i}x', + style: TextStyle( + color: i == playerController.playerSpeed + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + ), + ], + ], + ), + MenuAnchor( + consumeOutsideTap: true, + onOpen: () { + widget.cancelHideTimer(); + playerController.canHidePlayerPanel = false; + }, + onClose: () { + widget.cancelHideTimer(); + widget.startHideTimer(); + playerController.canHidePlayerPanel = true; + }, + builder: (BuildContext context, MenuController controller, + Widget? child) { + return IconButton( onPressed: () { - widget.handleFullscreen(); + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } }, - ), - ], + icon: const Icon( + Icons.aspect_ratio_rounded, + color: Colors.white, + ), + tooltip: '视频比例', + ); + }, + menuChildren: [ + for (final entry in aspectRatioTypeMap.entries) + MenuItemButton( + onPressed: () => + playerController.aspectRatioType = entry.key, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + entry.value, + style: TextStyle( + color: entry.key == + playerController.aspectRatioType + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + ), + ], + ), + (!videoPageController.isFullscreen && + !Utils.isTablet() && + !Utils.isDesktop()) || + !videoPageController.canShowBangumiPanel + ? Container() + : IconButton( + color: Colors.white, + icon: const Icon(Icons.menu_open_rounded), + tooltip: '选集面板', + onPressed: () { + videoPageController.showTabBody = + !videoPageController.showTabBody; + widget.openMenu(); + }, + ), + (Utils.isTablet() && + videoPageController.isFullscreen && + MediaQuery.of(context).size.height < + MediaQuery.of(context).size.width) + ? Container() + : IconButton( + color: Colors.white, + icon: Icon(videoPageController.isFullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded), + tooltip: videoPageController.isFullscreen + ? '退出全屏' + : '全屏', + onPressed: () { + widget.handleFullscreen(); + }, + ), + ], + ), ), - ), - if (Utils.isTablet() || Utils.isDesktop()) - const SizedBox(height: 6), - ], + if (Utils.isTablet() || Utils.isDesktop()) + const SizedBox(height: 6), + ], + ), ), - ), - ); - }); + ); + } + ); } Widget get topControlWidget { - return Observer(builder: (context) { - return EmbeddedNativeControlArea( - requireOffset: !videoPageController.isFullscreen, - child: SafeArea( - top: false, - bottom: false, - left: videoPageController.isFullscreen, - right: videoPageController.isFullscreen, - child: MouseRegion( - cursor: (videoPageController.isFullscreen && - !playerController.showVideoController) - ? SystemMouseCursors.none - : SystemMouseCursors.basic, - onEnter: (_) { - widget.cancelHideTimer(); - }, - onExit: (_) { - widget.cancelHideTimer(); - widget.startHideTimer(); - }, - child: Row( - children: [ - IconButton( - color: Colors.white, - icon: const Icon(Icons.arrow_back_rounded), - tooltip: '返回', - onPressed: () { - widget.onBackPressed(context); - }, - ), - // 拖动条 - Expanded( - child: dtb.DragToMoveArea( - child: Text( - ' ${videoPageController.title} [${videoPageController.roadList[videoPageController.currentRoad].identifier[videoPageController.currentEpisode - 1]}]', - style: TextStyle( - color: Colors.white, - fontSize: - Theme.of(context).textTheme.titleMedium!.fontSize, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - // 跳过 - forwardIcon(), - if ((Utils.isDesktop() && !videoPageController.isFullscreen) || - Platform.isAndroid) + return Observer( + builder: (context) { + return EmbeddedNativeControlArea( + requireOffset: !videoPageController.isFullscreen, + child: SafeArea( + top: false, + bottom: false, + left: videoPageController.isFullscreen, + right: videoPageController.isFullscreen, + child: MouseRegion( + cursor: (videoPageController.isFullscreen && + !playerController.showVideoController) + ? SystemMouseCursors.none + : SystemMouseCursors.basic, + onEnter: (_) { + widget.cancelHideTimer(); + }, + onExit: (_) { + widget.cancelHideTimer(); + widget.startHideTimer(); + }, + child: Row( + children: [ IconButton( - onPressed: () async { - if (Utils.isDesktop()) { - if (videoPageController.isPip) { - await PipUtils.exitDesktopPIPWindow(); - } else { - await PipUtils.enterDesktopPIPWindow( - width: playerController.playerWidth, - height: playerController.playerHeight, - ); - } - videoPageController.isPip = !videoPageController.isPip; - return; - } - final bool supported = - await PipUtils.isAndroidPIPSupported(); - if (!supported) { - KazumiDialog.showToast(message: '当前设备不支持画中画'); - return; - } - await PipUtils.updateAndroidPIPActions( - playing: playerController.playing, - danmakuEnabled: playerController.danmakuOn, - width: playerController.playerWidth, - height: playerController.playerHeight, - ); - final bool entered = await PipUtils.enterAndroidPIPWindow( - width: playerController.playerWidth, - height: playerController.playerHeight, - ); - if (!entered) { - KazumiDialog.showToast(message: '进入画中画失败'); - } + color: Colors.white, + icon: const Icon(Icons.arrow_back_rounded), + tooltip: '返回', + onPressed: () { + widget.onBackPressed(context); }, - tooltip: '画中画', - icon: const Icon( - Icons.picture_in_picture, - color: Colors.white, + ), + // 拖动条 + Expanded( + child: dtb.DragToMoveArea( + child: Text( + ' ${videoPageController.title} [${videoPageController.roadList[videoPageController.currentRoad].identifier[videoPageController.currentEpisode - 1]}]', + style: TextStyle( + color: Colors.white, + fontSize: + Theme.of(context).textTheme.titleMedium!.fontSize, + overflow: TextOverflow.ellipsis, + ), + ), ), ), - // 追番 - CollectButton( - bangumiItem: videoPageController.bangumiItem, - onOpen: () { - widget.cancelHideTimer(); - playerController.canHidePlayerPanel = false; - }, - onClose: () { - widget.cancelHideTimer(); - widget.startHideTimer(); - playerController.canHidePlayerPanel = true; - }, - ), - MenuAnchor( - consumeOutsideTap: true, - onOpen: () { - widget.cancelHideTimer(); - playerController.canHidePlayerPanel = false; - }, - onClose: () { - widget.cancelHideTimer(); - widget.startHideTimer(); - playerController.canHidePlayerPanel = true; - }, - builder: (BuildContext context, MenuController controller, - Widget? child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); + // 跳过 + forwardIcon(), + if ((Utils.isDesktop() && !videoPageController.isFullscreen) || Platform.isAndroid) + IconButton( + onPressed: () async { + if (Utils.isDesktop()) { + if (videoPageController.isPip) { + await PipUtils.exitDesktopPIPWindow(); + } else { + await PipUtils.enterDesktopPIPWindow( + width: playerController.playerWidth, + height: playerController.playerHeight, + ); + } + videoPageController.isPip = !videoPageController.isPip; + return; + } + final bool supported = + await PipUtils.isAndroidPIPSupported(); + if (!supported) { + KazumiDialog.showToast(message: '当前设备不支持画中画'); + return; + } + await PipUtils.updateAndroidPIPActions( + playing: playerController.playing, + danmakuEnabled: playerController.danmakuOn, + width: playerController.playerWidth, + height: playerController.playerHeight, + ); + final bool entered = await PipUtils.enterAndroidPIPWindow( + width: playerController.playerWidth, + height: playerController.playerHeight, + ); + if (!entered) { + KazumiDialog.showToast(message: '进入画中画失败'); } }, - tooltip: '更多选项', + tooltip: '画中画', icon: const Icon( - Icons.more_vert, + Icons.picture_in_picture, color: Colors.white, ), - ); - }, - menuChildren: [ - MenuItemButton( - onPressed: () { - widget.showDanmakuSwitch(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("弹幕切换"), - ), - ), - ), - MenuItemButton( - onPressed: () { - widget.showVideoInfo(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("视频详情"), - ), - ), ), - MenuItemButton( - onPressed: () { - bool needRestart = playerController.playing; - playerController.pause(); - RemotePlay() - .castVideo(playerController.videoUrl, - videoPageController.currentPlugin.referer) - .whenComplete(() { - if (needRestart) { - playerController.play(); + // 追番 + if (videoPageController.hasBangumiBinding) + CollectButton( + bangumiItem: videoPageController.bangumiItem, + onOpen: () { + widget.cancelHideTimer(); + playerController.canHidePlayerPanel = false; + }, + onClose: () { + widget.cancelHideTimer(); + widget.startHideTimer(); + playerController.canHidePlayerPanel = true; + }, + ), + MenuAnchor( + consumeOutsideTap: true, + onOpen: () { + widget.cancelHideTimer(); + playerController.canHidePlayerPanel = false; + }, + onClose: () { + widget.cancelHideTimer(); + widget.startHideTimer(); + playerController.canHidePlayerPanel = true; + }, + builder: (BuildContext context, MenuController controller, + Widget? child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); } - }); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("远程投屏"), - ), - ), - ), - MenuItemButton( - onPressed: () { - playerController.lanunchExternalPlayer(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("外部播放"), + }, + tooltip: '更多选项', + icon: const Icon( + Icons.more_vert, + color: Colors.white, ), - ), - ), - // 定时关闭 - SubmenuButton( - menuChildren: [ + ); + }, + menuChildren: [ + if (videoPageController.isLocalFilePlayback && + !videoPageController.hasBangumiBinding) MenuItemButton( onPressed: () { - TimedShutdownService().cancel(); + widget.showLocalBangumiBindSheet(); }, child: Container( height: 48, constraints: BoxConstraints(minWidth: 112), child: Align( alignment: Alignment.centerLeft, - child: Text( - "不开启", - style: TextStyle( - color: !TimedShutdownService().isActive - ? Theme.of(context).colorScheme.primary - : null, - ), - ), + child: Text("绑定番剧"), ), ), ), - for (final int minutes in [15, 30, 60]) + if (videoPageController.hasBangumiBinding) + MenuItemButton( + onPressed: () { + widget.showDanmakuSwitch(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("弹幕切换"), + ), + ), + ), + if (videoPageController.hasBangumiBinding) + MenuItemButton( + onPressed: () { + widget.showVideoInfo(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("视频详情"), + ), + ), + ), + MenuItemButton( + onPressed: () { + bool needRestart = playerController.playing; + playerController.pause(); + RemotePlay() + .castVideo(playerController.videoUrl, + videoPageController.isOfflineMode + ? '' + : videoPageController.currentPlugin.referer) + .whenComplete(() { + if (needRestart) { + playerController.play(); + } + }); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("远程投屏"), + ), + ), + ), + MenuItemButton( + onPressed: () { + playerController.lanunchExternalPlayer(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("外部播放"), + ), + ), + ), + // 定时关闭 + SubmenuButton( + menuChildren: [ MenuItemButton( onPressed: () { - TimedShutdownService().start(minutes, - onExpired: widget.pauseForTimedShutdown); - KazumiDialog.showToast( - message: - '已设置 ${TimedShutdownService().formatMinutesToDisplay(minutes)} 后定时关闭'); + TimedShutdownService().cancel(); }, child: Container( height: 48, @@ -1298,10 +1300,9 @@ class _PlayerItemPanelState extends State { child: Align( alignment: Alignment.centerLeft, child: Text( - "$minutes 分钟", + "不开启", style: TextStyle( - color: TimedShutdownService().setMinutes == - minutes + color: !TimedShutdownService().isActive ? Theme.of(context).colorScheme.primary : null, ), @@ -1309,162 +1310,185 @@ class _PlayerItemPanelState extends State { ), ), ), - MenuItemButton( - onPressed: () { - TimedShutdownService.showCustomTimerDialog( - onExpired: widget.pauseForTimedShutdown, - ); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("自定义"), + for (final int minutes in [15, 30, 60]) + MenuItemButton( + onPressed: () { + TimedShutdownService().start(minutes, onExpired: widget.pauseForTimedShutdown); + KazumiDialog.showToast(message: '已设置 ${TimedShutdownService().formatMinutesToDisplay(minutes)} 后定时关闭'); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "$minutes 分钟", + style: TextStyle( + color: TimedShutdownService().setMinutes == minutes + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), ), - ), - ), - ], - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: ValueListenableBuilder( - valueListenable: - TimedShutdownService().remainingSecondsNotifier, - builder: (context, remainingSeconds, child) { - return Text( - remainingSeconds > 0 - ? "定时关闭 (${TimedShutdownService().formatRemainingTime()})" - : "定时关闭", + MenuItemButton( + onPressed: () { + TimedShutdownService.showCustomTimerDialog( + onExpired: widget.pauseForTimedShutdown, ); }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("自定义"), + ), + ), + ), + ], + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: ValueListenableBuilder( + valueListenable: TimedShutdownService().remainingSecondsNotifier, + builder: (context, remainingSeconds, child) { + return Text( + remainingSeconds > 0 + ? "定时关闭 (${TimedShutdownService().formatRemainingTime()})" + : "定时关闭", + ); + }, + ), ), ), ), - ), - SubmenuButton( - menuChildren: [ - MenuItemButton( - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - "当前房间: ${playerController.syncplayRoom == '' ? '未加入' : playerController.syncplayRoom}"), + SubmenuButton( + menuChildren: [ + MenuItemButton( + child: Container( + + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "当前房间: ${playerController.syncplayRoom == '' ? '未加入' : playerController.syncplayRoom}"), + ), ), ), - ), - MenuItemButton( - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - "网络延时: ${playerController.syncplayClientRtt}ms"), + MenuItemButton( + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "网络延时: ${playerController.syncplayClientRtt}ms"), + ), ), ), - ), - MenuItemButton( - onPressed: () { - widget.showSyncPlayRoomCreateDialog(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("加入房间"), + MenuItemButton( + onPressed: () { + widget.showSyncPlayRoomCreateDialog(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("加入房间"), + ), ), ), - ), - MenuItemButton( - onPressed: () { - widget.showSyncPlayEndPointSwitchDialog(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("切换服务器"), + MenuItemButton( + onPressed: () { + widget.showSyncPlayEndPointSwitchDialog(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("切换服务器"), + ), ), ), - ), - MenuItemButton( - onPressed: () async { - await playerController.exitSyncPlayRoom(); - }, - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("断开连接"), + MenuItemButton( + onPressed: () async { + await playerController.exitSyncPlayRoom(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("断开连接"), + ), ), ), - ), - ], - child: Container( - height: 48, - constraints: BoxConstraints(minWidth: 112), - child: Align( - alignment: Alignment.centerLeft, - child: Text("一起看"), + ], + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("一起看"), + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), - ), - ); - }); + ); + } + ); } Widget get leftControlWidget { - return Observer(builder: (context) { - return SafeArea( - top: false, - bottom: false, - left: videoPageController.isFullscreen, - right: videoPageController.isFullscreen, - child: Column( - children: [ - const Spacer(), - (playerController.lockPanel) - ? Container() - : IconButton( - icon: const Icon( - Icons.photo_camera_outlined, - color: Colors.white, + return Observer( + builder: (context) { + return SafeArea( + top: false, + bottom: false, + left: videoPageController.isFullscreen, + right: videoPageController.isFullscreen, + child: Column( + children: [ + const Spacer(), + (playerController.lockPanel) + ? Container() + : IconButton( + icon: const Icon( + Icons.photo_camera_outlined, + color: Colors.white, + ), + tooltip: '截图', + onPressed: () { + widget.handleScreenShot(); + }, ), - tooltip: '截图', - onPressed: () { - widget.handleScreenShot(); - }, - ), - IconButton( - icon: Icon( - playerController.lockPanel - ? Icons.lock_outline - : Icons.lock_open, - color: Colors.white, + IconButton( + icon: Icon( + playerController.lockPanel ? Icons.lock_outline : Icons.lock_open, + color: Colors.white, + ), + tooltip: playerController.lockPanel ? '解锁面板' : '锁定面板', + onPressed: () { + playerController.lockPanel = !playerController.lockPanel; + }, ), - tooltip: playerController.lockPanel ? '解锁面板' : '锁定面板', - onPressed: () { - playerController.lockPanel = !playerController.lockPanel; - }, - ), - const Spacer(), - ], - ), - ); - }); + const Spacer(), + ], + ), + ); + } + ); } } diff --git a/lib/pages/player/player_item_surface.dart b/lib/pages/player/player_item_surface.dart index 1a03e7ef9..7bce73582 100644 --- a/lib/pages/player/player_item_surface.dart +++ b/lib/pages/player/player_item_surface.dart @@ -18,7 +18,9 @@ class _PlayerItemSurfaceState extends State { Widget build(BuildContext context) { return Observer(builder: (context) { if (playerController.loading || - playerController.videoController == null) { + playerController.videoController == null || + playerController.playerWidth <= 1 || + playerController.playerHeight <= 1) { return Container( color: Colors.black, child: const Center( @@ -28,6 +30,9 @@ class _PlayerItemSurfaceState extends State { } return Video( + key: ValueKey( + '${playerController.playbackSession}-${identityHashCode(playerController.videoController)}', + ), controller: playerController.videoController!, controls: NoVideoControls, pauseUponEnteringBackgroundMode: false, diff --git a/lib/pages/player/smallest_player_item_panel.dart b/lib/pages/player/smallest_player_item_panel.dart index 5a8087460..984b6c6b8 100644 --- a/lib/pages/player/smallest_player_item_panel.dart +++ b/lib/pages/player/smallest_player_item_panel.dart @@ -41,6 +41,7 @@ class SmallestPlayerItemPanel extends StatefulWidget { required this.showSyncPlayRoomCreateDialog, required this.showSyncPlayEndPointSwitchDialog, required this.pauseForTimedShutdown, + required this.showLocalBangumiBindSheet, this.disableAnimations = false, }); @@ -62,6 +63,7 @@ class SmallestPlayerItemPanel extends StatefulWidget { final void Function() showSyncPlayRoomCreateDialog; final void Function() showSyncPlayEndPointSwitchDialog; final VoidCallback pauseForTimedShutdown; + final VoidCallback showLocalBangumiBindSheet; final bool disableAnimations; @override @@ -604,8 +606,10 @@ class _SmallestPlayerItemPanelState extends State { icon: const Icon(Icons.picture_in_picture, color: Colors.white)), // 弹幕开关 - _buildDanmakuToggleButton(context), + if (videoPageController.hasBangumiBinding) + _buildDanmakuToggleButton(context), // 追番 + if (videoPageController.hasBangumiBinding) CollectButton( bangumiItem: videoPageController.bangumiItem, onOpen: () { @@ -647,6 +651,21 @@ class _SmallestPlayerItemPanelState extends State { ); }, menuChildren: [ + if (videoPageController.isLocalFilePlayback && + !videoPageController.hasBangumiBinding) + MenuItemButton( + onPressed: () { + widget.showLocalBangumiBindSheet(); + }, + child: Container( + height: 48, + constraints: BoxConstraints(minWidth: 112), + child: Align( + alignment: Alignment.centerLeft, + child: Text("绑定番剧"), + ), + ), + ), SubmenuButton( menuChildren: List.generate( 3, @@ -827,6 +846,7 @@ class _SmallestPlayerItemPanelState extends State { ), ), ), + if (videoPageController.hasBangumiBinding) MenuItemButton( onPressed: () { widget.showDanmakuSwitch(); @@ -840,6 +860,7 @@ class _SmallestPlayerItemPanelState extends State { ), ), ), + if (videoPageController.hasBangumiBinding) MenuItemButton( onPressed: () { showModalBottomSheet( @@ -869,6 +890,7 @@ class _SmallestPlayerItemPanelState extends State { ), ), ), + if (videoPageController.hasBangumiBinding) MenuItemButton( onPressed: () { widget.showVideoInfo(); @@ -888,7 +910,9 @@ class _SmallestPlayerItemPanelState extends State { playerController.pause(); RemotePlay() .castVideo(playerController.videoUrl, - videoPageController.currentPlugin.referer) + videoPageController.isOfflineMode + ? '' + : videoPageController.currentPlugin.referer) .whenComplete(() { if (needRestart) { playerController.play(); diff --git a/lib/pages/video/video_controller.dart b/lib/pages/video/video_controller.dart index 8319328d7..aeadf5ebf 100644 --- a/lib/pages/video/video_controller.dart +++ b/lib/pages/video/video_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:kazumi/modules/roads/road_module.dart'; import 'package:kazumi/plugins/plugins_controller.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -21,6 +22,7 @@ import 'package:kazumi/request/apis/bangumi_api.dart'; import 'package:dio/dio.dart'; import 'package:hive_ce/hive.dart'; import 'package:kazumi/utils/storage.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; part 'video_controller.g.dart'; @@ -71,6 +73,10 @@ abstract class _VideoPageController with Store { @observable bool isOfflineMode = false; + PlaybackSourceType sourceType = PlaybackSourceType.online; + + LocalVideoPlaybackContext? localVideoContext; + /// 离线视频本地路径 String? _offlineVideoPath; @@ -97,6 +103,38 @@ abstract class _VideoPageController with Store { final IDownloadManager downloadManager = Modular.get(); final Box setting = GStorage.setting; + bool get hasBangumiBinding { + if (sourceType == PlaybackSourceType.localFile) { + return localVideoContext?.boundBangumiItem != null; + } + return bangumiItem.id > 0; + } + + bool get isLocalFilePlayback => sourceType == PlaybackSourceType.localFile; + + bool get canShowBangumiPanel => !isLocalFilePlayback || hasBangumiBinding; + + static BangumiItem buildUnboundLocalVideoItem(String title) { + final displayTitle = title.trim().isEmpty ? '本地视频' : title.trim(); + return BangumiItem( + id: 0, + type: 2, + name: displayTitle, + nameCn: displayTitle, + summary: '', + airDate: '', + airWeekday: 0, + rank: 0, + images: const {'large': ''}, + tags: const [], + alias: const [], + ratingScore: 0.0, + votes: 0, + votesCount: const [], + info: '', + ); + } + /// 长生命周期的视频源提供者(页面生命周期内复用,WebView 实例在 Provider 内复用) WebViewVideoSourceProvider? _videoSourceProvider; @@ -120,9 +158,12 @@ abstract class _VideoPageController with Store { }) { this.bangumiItem = bangumiItem; _offlinePluginName = pluginName; + sourceType = PlaybackSourceType.downloaded; + localVideoContext = null; currentRoad = road; title = bangumiItem.nameCn.isNotEmpty ? bangumiItem.nameCn : bangumiItem.name; + src = videoPath; isOfflineMode = true; _offlineVideoPath = videoPath; // 离线模式不需要解析视频源,直接设置 loading 为 false @@ -144,6 +185,46 @@ abstract class _VideoPageController with Store { 'VideoPageController: initialized for offline playback, episode $episodeNumber (position: $currentEpisode)'); } + /// 初始化直接打开的本地视频播放模式 + void initForLocalFilePlayback({ + required LocalVideoPlaybackContext context, + BangumiItem? boundBangumiItem, + int episodeNumber = 1, + }) { + final displayTitle = + context.title.trim().isEmpty ? '本地视频' : context.title.trim(); + final actualEpisodeNumber = episodeNumber < 1 ? 1 : episodeNumber; + final playbackContext = context.copyWith( + boundBangumiItem: boundBangumiItem, + boundEpisode: actualEpisodeNumber, + clearBangumiBinding: boundBangumiItem == null, + ); + localVideoContext = playbackContext; + sourceType = PlaybackSourceType.localFile; + bangumiItem = boundBangumiItem ?? buildUnboundLocalVideoItem(displayTitle); + _offlinePluginName = localVideoHistoryAdapterName; + currentRoad = 0; + currentEpisode = 1; + title = boundBangumiItem == null + ? displayTitle + : (boundBangumiItem.nameCn.isNotEmpty + ? boundBangumiItem.nameCn + : boundBangumiItem.name); + src = context.path; + isOfflineMode = true; + _offlineVideoPath = context.path; + loading = false; + errorMessage = null; + showTabBody = boundBangumiItem != null; + roadList + ..clear() + ..add(Road( + name: '播放列表1', + data: [actualEpisodeNumber.toString()], + identifier: [displayTitle], + )); + } + /// 构建离线模式的 roadList void _buildOfflineRoadList(List episodes) { roadList.clear(); @@ -162,6 +243,8 @@ abstract class _VideoPageController with Store { void resetOfflineMode() { isOfflineMode = false; + sourceType = PlaybackSourceType.online; + localVideoContext = null; _offlineVideoPath = null; _offlinePluginName = ''; } @@ -174,7 +257,9 @@ abstract class _VideoPageController with Store { /// 在线模式下直接返回 currentEpisode /// 离线模式下从 roadList.data 中获取实际的 episodeNumber int get actualEpisodeNumber { - if (isOfflineMode && roadList.isNotEmpty) { + if ((sourceType == PlaybackSourceType.downloaded || + sourceType == PlaybackSourceType.localFile) && + roadList.isNotEmpty) { try { return int.parse(roadList[currentRoad].data[currentEpisode - 1]); } catch (_) { @@ -191,7 +276,7 @@ abstract class _VideoPageController with Store { errorMessage = null; if (isOfflineMode) { - await _changeOfflineEpisode(episode, 0); + await _changeOfflineEpisode(episode, offset); return; } @@ -211,6 +296,42 @@ abstract class _VideoPageController with Store { /// 离线模式下切换集数 /// [episode] 是列表中的位置(从 1 开始),需要从 roadList.data 中获取实际的 episodeNumber Future _changeOfflineEpisode(int episode, int offset) async { + if (sourceType == PlaybackSourceType.localFile) { + final localPath = _offlineVideoPath; + if (localPath == null || + localPath.isEmpty || + !File(localPath).existsSync()) { + KazumiDialog.showToast(message: '本地文件不存在或已移动'); + return; + } + final actualEpisodeNumber = + int.tryParse(roadList[currentRoad].data[episode - 1]) ?? episode; + loading = false; + final params = PlaybackInitParams( + videoUrl: localPath, + offset: offset, + isLocalPlayback: true, + sourceType: PlaybackSourceType.localFile, + localVideoContext: localVideoContext, + bangumiId: bangumiItem.id, + pluginName: _offlinePluginName, + episode: actualEpisodeNumber, + httpHeaders: {}, + adBlockerEnabled: false, + episodeTitle: roadList[currentRoad].identifier[episode - 1], + referer: '', + currentRoad: currentRoad, + coverUrl: bangumiItem.images['large'], + bangumiName: bangumiItem.nameCn.isNotEmpty + ? bangumiItem.nameCn + : bangumiItem.name, + ); + + final playerController = Modular.get(); + await playerController.init(params); + return; + } + // 从 roadList.data 中获取实际的 episodeNumber final actualEpisodeNumber = int.tryParse(roadList[currentRoad].data[episode - 1]); @@ -231,6 +352,7 @@ abstract class _VideoPageController with Store { return; } _offlineVideoPath = localPath; + src = localPath; loading = false; KazumiLogger().i( @@ -240,6 +362,7 @@ abstract class _VideoPageController with Store { videoUrl: localPath, offset: offset, isLocalPlayback: true, + sourceType: PlaybackSourceType.downloaded, bangumiId: bangumiItem.id, pluginName: _offlinePluginName, episode: actualEpisodeNumber, @@ -297,6 +420,7 @@ abstract class _VideoPageController with Store { videoUrl: source.url, offset: source.offset, isLocalPlayback: false, + sourceType: PlaybackSourceType.online, bangumiId: bangumiItem.id, pluginName: currentPlugin.name, episode: currentEpisode, diff --git a/lib/pages/video/video_page.dart b/lib/pages/video/video_page.dart index c7b529e28..83b136ac8 100644 --- a/lib/pages/video/video_page.dart +++ b/lib/pages/video/video_page.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'package:canvas_danmaku/models/danmaku_content_item.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/pages/player/player_controller.dart'; import 'package:kazumi/pages/video/video_controller.dart'; import 'package:kazumi/pages/history/history_controller.dart'; +import 'package:kazumi/modules/history/history_module.dart'; import 'package:kazumi/utils/logger.dart'; import 'package:kazumi/pages/player/player_item.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -124,16 +126,47 @@ class _VideoPageState extends State } void _initOfflineMode() { - videoPageController.showTabBody = true; + videoPageController.showTabBody = videoPageController.canShowBangumiPanel; videoPageController.historyOffset = 0; currentRoad = videoPageController.currentRoad; + Progress? progress; + if (videoPageController.isLocalFilePlayback) { + for (final history in historyController.histories) { + if (history.isLocalVideo && + history.localVideoPath == videoPageController.offlineVideoPath) { + progress = + history.progresses[videoPageController.actualEpisodeNumber]; + break; + } + } + } else { + progress = historyController.findProgress( + videoPageController.bangumiItem, + videoPageController.offlinePluginName, + videoPageController.actualEpisodeNumber, + ); + } + if (progress != null && playResume) { + videoPageController.historyOffset = progress.progress.inSeconds; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + return; + } if (videoPageController.offlineVideoPath != null) { + if (videoPageController.isLocalFilePlayback && + !File(videoPageController.offlineVideoPath!).existsSync()) { + KazumiDialog.showToast(message: '本地文件不存在或已移动'); + return; + } final params = PlaybackInitParams( videoUrl: videoPageController.offlineVideoPath!, offset: videoPageController.historyOffset, isLocalPlayback: true, + sourceType: videoPageController.sourceType, + localVideoContext: videoPageController.localVideoContext, bangumiId: videoPageController.bangumiItem.id, pluginName: videoPageController.offlinePluginName, episode: videoPageController.actualEpisodeNumber, @@ -190,6 +223,9 @@ class _VideoPageState extends State // 使用 Provider 模式启动播放 WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } changeEpisode(videoPageController.currentEpisode, currentRoad: videoPageController.currentRoad, offset: videoPageController.historyOffset); @@ -288,6 +324,9 @@ class _VideoPageState extends State void menuJumpToCurrentEpisode() { Future.delayed(const Duration(milliseconds: 20), () async { + if (!mounted) { + return; + } await observerController.jumpTo( index: videoPageController.currentEpisode > 1 ? videoPageController.currentEpisode - 1 @@ -308,12 +347,17 @@ class _VideoPageState extends State if (!disableAnimations) { animation.reverse(); Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) { + return; + } videoPageController.showTabBody = false; }); } else { videoPageController.showTabBody = false; } - keyboardFocus.requestFocus(); + if (mounted) { + keyboardFocus.requestFocus(); + } } void onBackPressed(BuildContext context) async { @@ -349,6 +393,9 @@ class _VideoPageState extends State /// 发送弹幕 由于接口限制, 暂时未提交云端 void sendDanmaku(String msg) async { + if (!mounted) { + return; + } keyboardFocus.requestFocus(); if (playerController.danDanmakus.isEmpty) { KazumiDialog.showToast( @@ -498,6 +545,9 @@ class _VideoPageState extends State ); if (result != null) { + if (!mounted) { + return; + } setState(() {}); playerController.danmakuDestination = result; sendDanmaku(msg); @@ -509,7 +559,12 @@ class _VideoPageState extends State final bool islandScape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height; WidgetsBinding.instance.addPostFrameCallback((_) { - openTabBodyAnimated(); + if (!mounted) { + return; + } + if (videoPageController.canShowBangumiPanel) { + openTabBodyAnimated(); + } }); return PopScope( canPop: false, @@ -558,7 +613,9 @@ class _VideoPageState extends State ), ), // when not wideScreen, show tabBody on the bottom - if (!islandScape) Expanded(child: tabBody), + if (!islandScape && + videoPageController.canShowBangumiPanel) + Expanded(child: tabBody), ], ), diff --git a/lib/repositories/history_repository.dart b/lib/repositories/history_repository.dart index 297dabd60..abdcc6a62 100644 --- a/lib/repositories/history_repository.dart +++ b/lib/repositories/history_repository.dart @@ -1,6 +1,7 @@ import 'package:kazumi/utils/storage.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/modules/history/history_module.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; import 'package:kazumi/utils/history_sync_service.dart'; import 'package:kazumi/utils/logger.dart'; @@ -35,6 +36,10 @@ abstract class IHistoryRepository { required Duration progress, required String lastSrc, required String lastWatchEpisodeName, + String localPath = '', + String episodeTitle = '', + PlaybackSourceType sourceType = PlaybackSourceType.online, + LocalVideoPlaybackContext? localVideoContext, }); /// 获取上次观看的进度 @@ -124,6 +129,10 @@ class HistoryRepository implements IHistoryRepository { required Duration progress, required String lastSrc, required String lastWatchEpisodeName, + String localPath = '', + String episodeTitle = '', + PlaybackSourceType sourceType = PlaybackSourceType.online, + LocalVideoPlaybackContext? localVideoContext, }) async { try { // 检查隐私模式 @@ -131,13 +140,31 @@ class HistoryRepository implements IHistoryRepository { return; } + final historyKey = History.getKey( + adapterName, + bangumiItem, + sourceTypeName: sourceType.name, + localVideoPath: localVideoContext?.path ?? localPath, + ); + // 获取或创建历史记录 - var history = - _historiesBox.get(History.getKey(adapterName, bangumiItem)) ?? - History(bangumiItem, episode, adapterName, DateTime.now(), - lastSrc, lastWatchEpisodeName); + var history = _historiesBox.get(historyKey) ?? + History( + bangumiItem, + episode, + adapterName, + DateTime.now(), + lastSrc, + lastWatchEpisodeName, + sourceTypeName: sourceType.name, + localVideoPath: localVideoContext?.path ?? localPath, + localVideoTitle: localVideoContext?.title ?? '', + localVideoFileName: localVideoContext?.fileName ?? '', + ); // 更新历史记录 + history.bangumiItem = bangumiItem; + history.adapterName = adapterName; history.lastWatchEpisode = episode; history.lastWatchTime = DateTime.now(); if (lastSrc.isNotEmpty) { @@ -146,14 +173,32 @@ class HistoryRepository implements IHistoryRepository { if (lastWatchEpisodeName.isNotEmpty) { history.lastWatchEpisodeName = lastWatchEpisodeName; } + history.sourceTypeName = sourceType.name; + if (sourceType == PlaybackSourceType.localFile) { + history.localVideoPath = localVideoContext?.path ?? localPath; + history.localVideoTitle = localVideoContext?.title ?? episodeTitle; + history.localVideoFileName = localVideoContext?.fileName ?? ''; + } // 更新观看进度 var prog = history.progresses[episode]; if (prog == null) { - history.progresses[episode] = - Progress(episode, road, progress.inMilliseconds); + history.progresses[episode] = Progress( + episode, + road, + progress.inMilliseconds, + localPath: localPath, + episodeTitle: episodeTitle, + ); } else { prog.progress = progress; + prog.road = road; + if (localPath.isNotEmpty) { + prog.localPath = localPath; + } + if (episodeTitle.isNotEmpty) { + prog.episodeTitle = episodeTitle; + } } // 保存到存储 diff --git a/lib/services/local_video_picker_service.dart b/lib/services/local_video_picker_service.dart new file mode 100644 index 000000000..22864d509 --- /dev/null +++ b/lib/services/local_video_picker_service.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:kazumi/modules/playback/playback_source.dart'; +import 'package:path/path.dart' as p; + +class LocalVideoPickerService { + Future pickVideo() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.video, + allowMultiple: false, + withData: false, + ); + if (result == null || result.files.isEmpty) { + return null; + } + + final path = result.files.single.path; + if (path == null || path.isEmpty) { + return null; + } + + return buildContext(path); + } + + LocalVideoPlaybackContext buildContext(String path) { + final file = File(path); + final fileName = p.basename(path); + final title = p.basenameWithoutExtension(path); + FileStat? stat; + try { + stat = file.statSync(); + } catch (_) { + stat = null; + } + + return LocalVideoPlaybackContext( + path: path, + title: title.isEmpty ? '本地视频' : title, + fileName: fileName, + fileSize: stat?.size ?? 0, + lastModified: stat?.modified, + ); + } +} diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index 2f0b8e76b..0b13c8f07 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -1,8 +1,18 @@ import 'package:flutter/material.dart'; extension ImageExtension on num { - int cacheSize(BuildContext context) { - return (this * MediaQuery.of(context).devicePixelRatio).round(); + int? cacheSize(BuildContext context) { + final value = toDouble(); + if (!value.isFinite || value <= 0) { + return null; + } + + final scaled = value * MediaQuery.of(context).devicePixelRatio; + if (!scaled.isFinite || scaled <= 0) { + return null; + } + + final result = scaled.round(); + return result > 0 ? result : null; } } - diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 746570488..491244c6c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import audio_service import audio_session import connectivity_plus import dynamic_color +import file_picker import file_selector_macos import flutter_inappwebview_macos import flutter_volume_controller @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 031a1bba3..3016a24ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -443,6 +443,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" file_selector_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 016c143ad..9fe7a37b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,6 +101,7 @@ dependencies: path: any photo_view: ^0.15.0 image_picker: ^1.2.1 + file_picker: ^10.3.3 webview_windows: git: url: https://github.com/Predidit/flutter-webview-windows.git