diff --git a/README.md b/README.md index 0716e870..97f66dae 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Miru App - [x] 扩展设置 - [ ] 漫画小说视频设置 - [ ] 漫画小说历史记录 -- [ ] 影视播放进度 +- [x] 影视播放进度 - [ ] 自动搜寻字幕 ## 截图 diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 11e1023a..10b08feb 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -89,9 +89,8 @@ "episodes": "Episodes", "watch-now": "Watch Now", "no-episodes": "No episodes", - "already-first": "Already on the first episode", - "already-last": "Already on the last episode", - "play-complete": "Playback complete" + "play-complete": "Playback complete", + "resume-last-playback": "Resume last playback" }, "reader": { diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 369e2fc1..f5968ad5 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -94,9 +94,8 @@ "episodes": "剧集", "watch-now": "立即观看", "no-episodes": "暂无剧集", - "already-first": "当前已经是第一集", - "already-last": "当前已经是最后一集", - "play-complete": "播放完成" + "play-complete": "播放完成", + "resume-last-playback": "恢复上次播放位置" }, "reader": { diff --git a/lib/models/history.dart b/lib/models/history.dart index 93bb0b8e..41fc74fc 100644 --- a/lib/models/history.dart +++ b/lib/models/history.dart @@ -21,5 +21,9 @@ class History { late String title; // 进度标题 late String episodeTitle; + // 当前剧集/章节进度 + late String progress; + // 当前章节/剧集总进度 + late String totalProgress; DateTime date = DateTime.now(); } diff --git a/lib/models/history.g.dart b/lib/models/history.g.dart index e466f106..1b0ce4f8 100644 --- a/lib/models/history.g.dart +++ b/lib/models/history.g.dart @@ -47,19 +47,29 @@ const HistorySchema = CollectionSchema( name: r'package', type: IsarType.string, ), - r'title': PropertySchema( + r'progress': PropertySchema( id: 6, + name: r'progress', + type: IsarType.string, + ), + r'title': PropertySchema( + id: 7, name: r'title', type: IsarType.string, ), + r'totalProgress': PropertySchema( + id: 8, + name: r'totalProgress', + type: IsarType.string, + ), r'type': PropertySchema( - id: 7, + id: 9, name: r'type', type: IsarType.string, enumMap: _HistorytypeEnumValueMap, ), r'url': PropertySchema( - id: 8, + id: 10, name: r'url', type: IsarType.string, ) @@ -106,7 +116,9 @@ int _historyEstimateSize( bytesCount += 3 + object.cover.length * 3; bytesCount += 3 + object.episodeTitle.length * 3; bytesCount += 3 + object.package.length * 3; + bytesCount += 3 + object.progress.length * 3; bytesCount += 3 + object.title.length * 3; + bytesCount += 3 + object.totalProgress.length * 3; bytesCount += 3 + object.type.name.length * 3; bytesCount += 3 + object.url.length * 3; return bytesCount; @@ -124,9 +136,11 @@ void _historySerialize( writer.writeLong(offsets[3], object.episodeId); writer.writeString(offsets[4], object.episodeTitle); writer.writeString(offsets[5], object.package); - writer.writeString(offsets[6], object.title); - writer.writeString(offsets[7], object.type.name); - writer.writeString(offsets[8], object.url); + writer.writeString(offsets[6], object.progress); + writer.writeString(offsets[7], object.title); + writer.writeString(offsets[8], object.totalProgress); + writer.writeString(offsets[9], object.type.name); + writer.writeString(offsets[10], object.url); } History _historyDeserialize( @@ -143,10 +157,12 @@ History _historyDeserialize( object.episodeTitle = reader.readString(offsets[4]); object.id = id; object.package = reader.readString(offsets[5]); - object.title = reader.readString(offsets[6]); - object.type = _HistorytypeValueEnumMap[reader.readStringOrNull(offsets[7])] ?? + object.progress = reader.readString(offsets[6]); + object.title = reader.readString(offsets[7]); + object.totalProgress = reader.readString(offsets[8]); + object.type = _HistorytypeValueEnumMap[reader.readStringOrNull(offsets[9])] ?? ExtensionType.manga; - object.url = reader.readString(offsets[8]); + object.url = reader.readString(offsets[10]); return object; } @@ -172,9 +188,13 @@ P _historyDeserializeProp

( case 6: return (reader.readString(offset)) as P; case 7: + return (reader.readString(offset)) as P; + case 8: + return (reader.readString(offset)) as P; + case 9: return (_HistorytypeValueEnumMap[reader.readStringOrNull(offset)] ?? ExtensionType.manga) as P; - case 8: + case 10: return (reader.readString(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -974,6 +994,136 @@ extension HistoryQueryFilter }); } + QueryBuilder progressEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'progress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'progress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'progress', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder progressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'progress', + value: '', + )); + }); + } + + QueryBuilder progressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'progress', + value: '', + )); + }); + } + QueryBuilder titleEqualTo( String value, { bool caseSensitive = true, @@ -1104,6 +1254,138 @@ extension HistoryQueryFilter }); } + QueryBuilder totalProgressEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + totalProgressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'totalProgress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'totalProgress', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'totalProgress', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder totalProgressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'totalProgress', + value: '', + )); + }); + } + + QueryBuilder + totalProgressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'totalProgress', + value: '', + )); + }); + } + QueryBuilder typeEqualTo( ExtensionType value, { bool caseSensitive = true, @@ -1444,6 +1726,18 @@ extension HistoryQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByProgress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'progress', Sort.asc); + }); + } + + QueryBuilder sortByProgressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'progress', Sort.desc); + }); + } + QueryBuilder sortByTitle() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'title', Sort.asc); @@ -1456,6 +1750,18 @@ extension HistoryQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByTotalProgress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'totalProgress', Sort.asc); + }); + } + + QueryBuilder sortByTotalProgressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'totalProgress', Sort.desc); + }); + } + QueryBuilder sortByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -1567,6 +1873,18 @@ extension HistoryQuerySortThenBy }); } + QueryBuilder thenByProgress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'progress', Sort.asc); + }); + } + + QueryBuilder thenByProgressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'progress', Sort.desc); + }); + } + QueryBuilder thenByTitle() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'title', Sort.asc); @@ -1579,6 +1897,18 @@ extension HistoryQuerySortThenBy }); } + QueryBuilder thenByTotalProgress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'totalProgress', Sort.asc); + }); + } + + QueryBuilder thenByTotalProgressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'totalProgress', Sort.desc); + }); + } + QueryBuilder thenByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -1645,6 +1975,13 @@ extension HistoryQueryWhereDistinct }); } + QueryBuilder distinctByProgress( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'progress', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByTitle( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -1652,6 +1989,14 @@ extension HistoryQueryWhereDistinct }); } + QueryBuilder distinctByTotalProgress( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'totalProgress', + caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByType( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -1711,12 +2056,24 @@ extension HistoryQueryProperty }); } + QueryBuilder progressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'progress'); + }); + } + QueryBuilder titleProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'title'); }); } + QueryBuilder totalProgressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'totalProgress'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/lib/pages/watch/widgets/reader/controller.dart b/lib/pages/watch/reader_controller.dart similarity index 96% rename from lib/pages/watch/widgets/reader/controller.dart rename to lib/pages/watch/reader_controller.dart index a144cb9a..4dd1cd6f 100644 --- a/lib/pages/watch/widgets/reader/controller.dart +++ b/lib/pages/watch/reader_controller.dart @@ -24,13 +24,12 @@ class ReaderController extends GetxController { late Rx watchData = Rx(null); final error = ''.obs; final isShowControlPanel = false.obs; - final index = 0.obs; + late final index = playIndex.obs; get cuurentPlayUrl => playList[index.value].url; Timer? _timer; @override void onInit() { - index.value = playIndex; getContent(); ever(index, (callback) => getContent()); super.onInit(); diff --git a/lib/pages/watch/video_controller.dart b/lib/pages/watch/video_controller.dart new file mode 100644 index 00000000..d7abd618 --- /dev/null +++ b/lib/pages/watch/video_controller.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:miru_app/models/index.dart'; +import 'package:miru_app/pages/home/controller.dart'; +import 'package:miru_app/utils/database.dart'; +import 'package:miru_app/utils/extension_runtime.dart'; +import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/utils/miru_directory.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:path/path.dart' as path; + +class VideoPlayerController extends GetxController { + final String title; + final List playList; + final String detailUrl; + final int playIndex; + final int episodeGroupId; + final ExtensionRuntime runtime; + + VideoPlayerController({ + required this.title, + required this.playList, + required this.detailUrl, + required this.playIndex, + required this.episodeGroupId, + required this.runtime, + }); + + final player = Player(); + late final VideoController videoController = VideoController(player); + final ScreenshotController screenshotController = ScreenshotController(); + final showPlayList = false.obs; + final isOpenSidebar = false.obs; + final isFullScreen = false.obs; + late final index = playIndex.obs; + final hideControlPanel = false.obs; + + // 是否已经自动跳转到上次播放进度 + bool _isAutoSeekPosition = false; + + List messageQueue = []; + + final Rx cuurentMessageWidget = Rx(null); + + @override + void onInit() { + if (Platform.isAndroid) { + // 切换到横屏 + SystemChrome.setPreferredOrientations( + [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + play(); + ever(index, (callback) { + play(); + }); + ever(showPlayList, (callback) { + if (!showPlayList.value) { + isOpenSidebar.value = false; + } + }); + // 自动切换下一集 + player.stream.completed.listen((event) { + if (index.value == playList.length - 1) { + addMessage(Message(Text('video.play-complete'.i18n))); + return; + } + if (!player.state.buffering) { + index.value++; + } + }); + + // 自动恢复上次播放进度 + player.stream.duration.listen((event) async { + if (_isAutoSeekPosition || event.inSeconds == 0) { + return; + } + // 获取上次播放进度 + final history = await DatabaseUtils.getHistoryByPackageAndUrl( + runtime.extension.package, + detailUrl, + ); + if (history != null && + history.progress.isNotEmpty && + history.episodeId == index.value && + history.episodeGroupId == episodeGroupId) { + _isAutoSeekPosition = true; + player.seek(Duration(seconds: int.parse(history.progress))); + addMessage(Message(Text('video.resume-last-playback'.i18n))); + } + }); + + super.onInit(); + } + + play() async { + try { + final playUrl = playList[index.value].url; + final m3u8Url = + (await runtime.watch(playUrl) as ExtensionBangumiWatch).url; + player.open(Media(m3u8Url)); + } catch (e) { + debugPrint(e.toString()); + addMessage( + Message( + Text(e.toString()), + time: const Duration(seconds: 5), + ), + ); + } + } + + toggleFullscreen() async { + await WindowManager.instance.setFullScreen(!isFullScreen.value); + isFullScreen.value = !isFullScreen.value; + } + + addHistory() async { + hideControlPanel.value = true; + if (player.state.position.inSeconds < 1) { + return; + } + final tempDir = await MiruDirectory.getCacheDirectory; + final coverDir = path.join(tempDir, 'history_cover'); + Directory(coverDir).createSync(recursive: true); + final epName = playList[index.value].name; + final filename = '${title}_$epName'; + final file = File(path.join(coverDir, filename)); + if (file.existsSync()) { + file.deleteSync(recursive: true); + } + + final captureData = await screenshotController.capture(); + file.writeAsBytes(captureData!).then((value) async { + debugPrint("save.."); + await DatabaseUtils.putHistory( + History() + ..url = detailUrl + ..cover = value.path + ..episodeGroupId = episodeGroupId + ..package = runtime.extension.package + ..type = runtime.extension.type + ..episodeId = index.value + ..episodeTitle = epName + ..title = title + ..progress = player.state.position.inSeconds.toString() + ..totalProgress = player.state.duration.inSeconds.toString(), + ); + await Get.find().onRefresh(); + }); + } + + addMessage(Message message) { + messageQueue.add(message); + + if (messageQueue.length == 1) { + _processNextMessage(); + } + } + + _processNextMessage() async { + if (messageQueue.isEmpty) { + cuurentMessageWidget.value = null; + return; + } + + final message = messageQueue.first; + cuurentMessageWidget.value = message.child; + // 等待消息显示完毕 + await Future.delayed(message.time); + messageQueue.removeAt(0); + _processNextMessage(); + } + + @override + void onClose() { + if (Platform.isAndroid) { + // 切换回竖屏 + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + } + super.onClose(); + } +} + +class Message { + final Widget child; + final Duration time; + + Message(this.child, {this.time = const Duration(seconds: 3)}); +} diff --git a/lib/pages/watch/widgets/reader/comic/comic_reader.dart b/lib/pages/watch/widgets/reader/comic/comic_reader.dart index 8ff0a0c3..90eb3dee 100644 --- a/lib/pages/watch/widgets/reader/comic/comic_reader.dart +++ b/lib/pages/watch/widgets/reader/comic/comic_reader.dart @@ -2,7 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:get/get.dart'; import 'package:miru_app/models/extension.dart'; import 'package:miru_app/pages/watch/widgets/reader/comic/comic_reader_content.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/pages/watch/widgets/reader/view.dart'; import 'package:miru_app/utils/extension_runtime.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/pages/watch/widgets/reader/comic/comic_reader_content.dart b/lib/pages/watch/widgets/reader/comic/comic_reader_content.dart index 5d847d3a..90481dab 100644 --- a/lib/pages/watch/widgets/reader/comic/comic_reader_content.dart +++ b/lib/pages/watch/widgets/reader/comic/comic_reader_content.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:miru_app/models/extension.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/widgets/button.dart'; import 'package:miru_app/widgets/cache_network_image.dart'; diff --git a/lib/pages/watch/widgets/reader/control_panel_footer.dart b/lib/pages/watch/widgets/reader/control_panel_footer.dart index b858d711..99563d4f 100644 --- a/lib/pages/watch/widgets/reader/control_panel_footer.dart +++ b/lib/pages/watch/widgets/reader/control_panel_footer.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/widgets/button.dart'; diff --git a/lib/pages/watch/widgets/reader/control_panel_header.dart b/lib/pages/watch/widgets/reader/control_panel_header.dart index 03ce466c..429bac4d 100644 --- a/lib/pages/watch/widgets/reader/control_panel_header.dart +++ b/lib/pages/watch/widgets/reader/control_panel_header.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:miru_app/pages/watch/widgets/playlist.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/router/router.dart'; import 'package:miru_app/utils/router.dart'; import 'package:miru_app/widgets/platform_widget.dart'; diff --git a/lib/pages/watch/widgets/reader/novel/novel_reader.dart b/lib/pages/watch/widgets/reader/novel/novel_reader.dart index 87427bea..60dad0c0 100644 --- a/lib/pages/watch/widgets/reader/novel/novel_reader.dart +++ b/lib/pages/watch/widgets/reader/novel/novel_reader.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:miru_app/models/index.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/pages/watch/widgets/reader/novel/novel_reader_content.dart'; import 'package:miru_app/pages/watch/widgets/reader/view.dart'; import 'package:miru_app/utils/extension_runtime.dart'; diff --git a/lib/pages/watch/widgets/reader/novel/novel_reader_content.dart b/lib/pages/watch/widgets/reader/novel/novel_reader_content.dart index 12970d31..0d765bb8 100644 --- a/lib/pages/watch/widgets/reader/novel/novel_reader_content.dart +++ b/lib/pages/watch/widgets/reader/novel/novel_reader_content.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:miru_app/models/extension.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/widgets/button.dart'; import 'package:miru_app/widgets/platform_widget.dart'; diff --git a/lib/pages/watch/widgets/reader/view.dart b/lib/pages/watch/widgets/reader/view.dart index 7ad35521..9aad9036 100644 --- a/lib/pages/watch/widgets/reader/view.dart +++ b/lib/pages/watch/widgets/reader/view.dart @@ -2,7 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:get/get.dart'; import 'package:miru_app/pages/watch/widgets/reader/control_panel_footer.dart'; import 'package:miru_app/pages/watch/widgets/reader/control_panel_header.dart'; -import 'package:miru_app/pages/watch/widgets/reader/controller.dart'; +import 'package:miru_app/pages/watch/reader_controller.dart'; class ReadView extends StatelessWidget { const ReadView( diff --git a/lib/pages/watch/widgets/video/video_player.dart b/lib/pages/watch/widgets/video/video_player.dart index 558cb1d5..d0bbe7d1 100644 --- a/lib/pages/watch/widgets/video/video_player.dart +++ b/lib/pages/watch/widgets/video/video_player.dart @@ -1,25 +1,12 @@ -import 'dart:io'; - -import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/models/extension.dart'; -import 'package:miru_app/models/history.dart'; -import 'package:miru_app/pages/home/controller.dart'; -import 'package:miru_app/router/router.dart'; -import 'package:miru_app/utils/database.dart'; +import 'package:miru_app/pages/watch/video_controller.dart'; +import 'package:miru_app/pages/watch/widgets/playlist.dart'; +import 'package:miru_app/pages/watch/widgets/video/video_player_content.dart'; import 'package:miru_app/utils/extension_runtime.dart'; -import 'package:miru_app/utils/i18n.dart'; -import 'package:miru_app/utils/miru_directory.dart'; import 'package:miru_app/widgets/platform_widget.dart'; -import 'package:miru_app/widgets/progress_ring.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:screenshot/screenshot.dart'; -import '../playlist.dart' as p; -import 'package:path/path.dart' as path; class VideoPlayer extends StatefulWidget { const VideoPlayer({ @@ -38,572 +25,103 @@ class VideoPlayer extends StatefulWidget { final int playerIndex; final int episodeGroupId; final ExtensionRuntime runtime; - @override State createState() => _VideoPlayerState(); } class _VideoPlayerState extends State { - late final _player = Player(); - late final _controller = VideoController(_player); - late final ScreenshotController _screenshotController = - ScreenshotController(); - late int _playerIndex = widget.playerIndex; - bool _isPlaying = false; - bool _isLoading = true; - bool _isFullScreen = false; - bool _showControl = false; - bool _showPlayList = false; - bool _openSidebar = false; - // 是否是进度条拖动 - bool _isSeeking = false; - Duration _duration = Duration.zero; - Duration _position = Duration.zero; - String _error = ''; + late VideoPlayerController _c; @override void initState() { - if (Platform.isAndroid) { - // 切换到横屏 - SystemChrome.setPreferredOrientations( - [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - } - _play(); - _player.stream.playing.listen((event) { - setState(() { - _isPlaying = event; - }); - }); - _player.stream.duration.listen((event) { - setState(() { - _duration = event; - }); - }); - _player.stream.position.listen((event) { - if (!_isSeeking) { - setState(() { - _position = event; - }); - } - }); - _player.stream.error.listen((event) { - if (event.toString().isNotEmpty) { - setState(() { - _error = event.toString(); - }); - } - }); - _player.stream.completed.listen((event) { - if (_playerIndex == widget.playList.length - 1) { - if (Platform.isAndroid) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('video.play-complete'.i18n), - )); - return; - } - fluent.displayInfoBar(context, builder: ((context, close) { - return fluent.InfoBar(title: Text('video.play-complete'.i18n)); - })); - return; - } - if (!_isLoading) { - _togglePlayIndex(index: _playerIndex + 1); - } - }); - + _c = Get.put( + VideoPlayerController( + title: widget.title, + playList: widget.playList, + detailUrl: widget.detailUrl, + playIndex: widget.playerIndex, + episodeGroupId: widget.episodeGroupId, + runtime: widget.runtime, + ), + tag: widget.title, + ); super.initState(); } @override void dispose() { - if (Platform.isAndroid) { - // 切换回竖屏 - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.edgeToEdge, - ); - } - _player.dispose(); + _c.player.dispose(); + Get.delete(tag: widget.title); super.dispose(); } - _addHistory() async { - if (_position.inSeconds < 1) { - return; - } - final tempDir = await MiruDirectory.getCacheDirectory; - final coverDir = path.join(tempDir, 'history_cover'); - Directory(coverDir).createSync(recursive: true); - final epName = widget.playList[_playerIndex].name; - final filename = '${widget.title}_$epName'; - final file = File(path.join(coverDir, filename)); - if (file.existsSync()) { - file.deleteSync(recursive: true); - } - - final coverPath = await _screenshotController.captureAndSave( - coverDir, - fileName: filename, - ); - await DatabaseUtils.putHistory( - History() - ..url = widget.detailUrl - ..cover = coverPath! - ..episodeGroupId = widget.episodeGroupId - ..package = widget.runtime.extension.package - ..type = widget.runtime.extension.type - ..episodeId = _playerIndex - ..episodeTitle = epName - ..title = widget.title, - ); - await Get.find().onRefresh(); - } - - _play() async { - _isLoading = true; - try { - final playUrl = widget.playList[_playerIndex].url; - final m3u8Url = - (await widget.runtime.watch(playUrl) as ExtensionBangumiWatch).url; - debugPrint(m3u8Url); - _player.open(Media(m3u8Url)); - _player.stream.buffering.listen((event) { - debugPrint(event.toString()); - _isLoading = event; - }); - } catch (e) { - debugPrint(e.toString()); - _error = e.toString(); - } finally { - if (mounted) { - setState(() {}); - } - } - } - - _togglePlayIndex({int index = 0}) async { - setState(() { - _playerIndex = index; - }); - _play(); - } - - // 头部控制面板 - _playerControlPanelHeader() { - return PlatformWidget( - androidWidget: Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - IconButton( - onPressed: () async { - await _addHistory(); - Get.back(); - }, - icon: const Icon( - Icons.arrow_back, - color: Colors.white, - ), - ), - const SizedBox(width: 8), - Text( - "${widget.title} - ${widget.playList[_playerIndex].name}", - style: const TextStyle( - fontSize: 18, - color: Colors.white, - ), - ) - ], - ), - ), - desktopWidget: Container( - height: 50, - margin: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all( - Radius.circular(10), - ), - ), - clipBehavior: Clip.antiAlias, - child: fluent.Acrylic( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - "${widget.title} - ${widget.playList[_playerIndex].name}", - style: const TextStyle(fontSize: 18), - ), - ), - ), - ), - const Spacer(), - // 关闭按钮 - Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all( - Radius.circular(10), - ), - ), - clipBehavior: Clip.antiAlias, - child: GestureDetector( - onTap: () async { - await _addHistory(); - await WindowManager.instance.setFullScreen(false); - router.pop(); - }, - child: const fluent.Acrylic( - child: Padding( - padding: EdgeInsets.all(11), - child: Icon( - fluent.FluentIcons.clear, - ), - ), - ), - ), - ) - ], - ), - ), - ); - } - - // 底部控制面板 - _playerControlPanel() { - final content = PlatformWidget( - androidWidget: Container( - padding: const EdgeInsets.all(8), + _buildContent() { + return Obx(() { + final maxWidth = MediaQuery.of(context).size.width; + return WillPopScope( + onWillPop: () async { + await _c.addHistory(); + return true; + }, child: Row( - children: [ - IconButton( - icon: const Icon( - Icons.skip_previous, - color: Colors.white, - ), - onPressed: () { - if (_playerIndex == 0) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('video.already-first'.i18n), - )); - return; - } - _togglePlayIndex(index: _playerIndex - 1); - }, - ), - - // 暂停播放按钮 - IconButton( - icon: Icon( - _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_fill, - color: Colors.white, - ), - onPressed: () { - _player.playOrPause(); - }, - ), - IconButton( - icon: const Icon( - Icons.skip_next, - color: Colors.white, - ), - onPressed: () { - if (_playerIndex == widget.playList.length - 1) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('video.already-last'.i18n), - )); - return; - } - _togglePlayIndex(index: _playerIndex + 1); - }, - ), - Expanded( - child: Slider( - label: _position.toString().split('.')[0], - value: _position.inMicroseconds.toDouble(), - max: _duration.inMicroseconds.toDouble(), - onChangeEnd: (value) { - _player.seek(_position); - setState(() { - _isSeeking = false; - }); - }, - onChangeStart: (value) { - setState(() { - _isSeeking = true; - }); - }, - onChanged: (double value) { - setState(() { - _position = Duration(microseconds: value.toInt()); - }); - }, - ), - ), - const SizedBox(width: 8), - // 进度指示器 - Text( - '${_position.toString().split('.')[0]} / ${_duration.toString().split('.')[0]}', - style: const TextStyle( - fontSize: 12, - color: Colors.white, - ), - ), - const SizedBox(width: 8), - IconButton( - onPressed: () { - setState(() { - if (_openSidebar) { - _showPlayList = false; - } - _openSidebar = !_openSidebar; - }); - }, - icon: const Icon( - Icons.list, - color: Colors.white, - ), - ) - ], - ), - ), - desktopWidget: Row( - children: [ - fluent.IconButton( - icon: const Icon(fluent.FluentIcons.previous), - onPressed: () { - if (_playerIndex == 0) { - fluent.displayInfoBar(context, builder: ((context, close) { - return fluent.InfoBar( - title: Text('video.already-first'.i18n)); - })); - return; - } - _togglePlayIndex(index: _playerIndex - 1); - }, - ), - fluent.IconButton( - icon: Icon(_isPlaying - ? fluent.FluentIcons.pause - : fluent.FluentIcons.play), - onPressed: () { - _player.playOrPause(); - }, - ), - fluent.IconButton( - icon: const Icon(fluent.FluentIcons.next), - onPressed: () { - if (_playerIndex == widget.playList.length - 1) { - fluent.displayInfoBar(context, builder: ((context, close) { - return fluent.InfoBar(title: Text('video.already-last'.i18n)); - })); - return; - } - _togglePlayIndex(index: _playerIndex + 1); - }, - ), - const SizedBox(width: 8), - Text( - _position.toString().split('.')[0], - style: const TextStyle(fontSize: 12), - ), - const SizedBox(width: 8), - Expanded( - child: fluent.Slider( - label: _position.toString().split('.')[0], - value: _position.inMicroseconds.toDouble(), - max: _duration.inMicroseconds.toDouble(), - onChangeEnd: (value) { - _player.seek(_position); - setState(() { - _isSeeking = false; - }); - }, - onChangeStart: (value) { - setState(() { - _isSeeking = true; - }); - }, - onChanged: (double value) { - setState(() { - _position = Duration(microseconds: value.toInt()); - }); - }, - ), - ), - const SizedBox(width: 8), - Text( - _duration.toString().split('.')[0], - style: const TextStyle(fontSize: 12), - ), - const SizedBox(width: 8), - fluent.IconButton( - icon: Icon(_isFullScreen - ? fluent.FluentIcons.back_to_window - : fluent.FluentIcons.full_screen), - onPressed: () async { - await WindowManager.instance.setFullScreen(!_isFullScreen); - setState(() { - _isFullScreen = !_isFullScreen; - }); - }), - fluent.IconButton( - icon: const Icon(fluent.FluentIcons.bulleted_list), - onPressed: () { - setState(() { - if (_openSidebar) { - _showPlayList = false; - } - _openSidebar = !_openSidebar; - }); - }), - fluent.IconButton( - icon: const Icon(fluent.FluentIcons.more), onPressed: () {}) - ], - ), - ); - - return PlatformWidget( - androidWidget: content, - desktopWidget: Container( - height: 50, - margin: const EdgeInsets.symmetric( - horizontal: 40, - vertical: 40, - ), - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - clipBehavior: Clip.antiAlias, - width: double.infinity, - child: fluent.Acrylic( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: content, - ), - ), - ), - ); - } - -// 中间控制面板 - _playerControlPanelCenter() { - if (_error.isNotEmpty) { - return SizedBox.expand( - child: Center( - child: Text( - _error, - style: const TextStyle(color: Colors.white), - ), - ), - ); - } - if (_isLoading) { - return const Center( - child: ProgressRing(), - ); - } - return const SizedBox.expand(); - } - - @override - Widget build(BuildContext context) { - final content = WillPopScope( - onWillPop: () async { - await _addHistory(); - debugPrint("onWillPop"); - return true; - }, - child: LayoutBuilder(builder: (context, container) { - return Row( children: [ AnimatedContainer( - width: - _openSidebar ? container.maxWidth - 300 : container.maxWidth, - duration: const Duration(milliseconds: 120), - curve: Curves.ease, onEnd: () { - setState(() { - if (_openSidebar) { - _showPlayList = true; - } - }); + _c.isOpenSidebar.value = _c.showPlayList.value; }, + width: _c.showPlayList.value + ? MediaQuery.of(context).size.width - 300 + : maxWidth, + duration: const Duration(milliseconds: 120), child: Stack( children: [ - Screenshot( - controller: _screenshotController, - child: Video( - controller: _controller, - controls: (state) => const SizedBox.shrink(), - ), - ), - Positioned.fill( - child: MouseRegion( - onHover: (event) { - if (!_showControl) { - setState(() { - _showControl = true; - }); - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - setState(() { - _showControl = false; - }); - } - }); - } - }, - child: Column( - children: [ - if (_showControl || _isLoading || !_isPlaying) - _playerControlPanelHeader(), - Expanded( - child: _isFullScreen - ? _playerControlPanelCenter() - : DragToMoveArea( - child: _playerControlPanelCenter(), - ), + VideoPlayerConten(tag: widget.title), + // 消息弹出 + if (_c.cuurentMessageWidget.value != null) + Positioned( + left: 0, + bottom: 100, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), ), - if (_showControl || _isLoading || !_isPlaying) - _playerControlPanel() - ], - ), + ), + constraints: BoxConstraints( + maxHeight: 200, + minWidth: 300, + maxWidth: maxWidth, + ), + child: _c.cuurentMessageWidget.value, + ).animate().fade(), ), - ), ], ), ), - - // 播放列表 - if (_showPlayList) + if (_c.isOpenSidebar.value) Expanded( - child: p.PlayList( - selectIndex: _playerIndex, + child: PlayList( + selectIndex: _c.index.value, list: widget.playList.map((e) => e.name).toList(), title: widget.title, onChange: (value) { - _togglePlayIndex(index: value); + _c.index.value = value; }, ), ) ], - ); - }), - ); - return PlatformWidget( - androidWidget: Scaffold( - body: content, - ), - desktopWidget: content, - ); + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: (context) => Scaffold(body: _buildContent()), + desktopBuilder: ((context) => _buildContent())); } } diff --git a/lib/pages/watch/widgets/video/video_player_.dart b/lib/pages/watch/widgets/video/video_player_.dart new file mode 100644 index 00000000..dad9bea5 --- /dev/null +++ b/lib/pages/watch/widgets/video/video_player_.dart @@ -0,0 +1,608 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:miru_app/models/extension.dart'; +import 'package:miru_app/models/history.dart'; +import 'package:miru_app/pages/home/controller.dart'; +import 'package:miru_app/router/router.dart'; +import 'package:miru_app/utils/database.dart'; +import 'package:miru_app/utils/extension_runtime.dart'; +import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/utils/miru_directory.dart'; +import 'package:miru_app/widgets/platform_widget.dart'; +import 'package:miru_app/widgets/progress_ring.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:screenshot/screenshot.dart'; +import '../playlist.dart' as p; +import 'package:path/path.dart' as path; + +class VideoPlayer extends StatefulWidget { + const VideoPlayer({ + Key? key, + required this.playList, + required this.runtime, + required this.episodeGroupId, + required this.playerIndex, + required this.title, + required this.detailUrl, + }) : super(key: key); + + final String title; + final List playList; + final String detailUrl; + final int playerIndex; + final int episodeGroupId; + final ExtensionRuntime runtime; + + @override + State createState() => _VideoPlayerState(); +} + +class _VideoPlayerState extends State { + late final _player = Player(); + late final _controller = VideoController(_player); + late final ScreenshotController _screenshotController = + ScreenshotController(); + late int _playerIndex = widget.playerIndex; + bool _isPlaying = false; + bool _isLoading = true; + bool _isFullScreen = false; + bool _showControl = false; + bool _showPlayList = false; + bool _openSidebar = false; + // 是否是进度条拖动 + bool _isSeeking = false; + Duration _duration = Duration.zero; + Duration _position = Duration.zero; + String _error = ''; + + @override + void initState() { + if (Platform.isAndroid) { + // 切换到横屏 + SystemChrome.setPreferredOrientations( + [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + _play(); + _player.stream.playing.listen((event) { + setState(() { + _isPlaying = event; + }); + }); + _player.stream.duration.listen((event) { + setState(() { + _duration = event; + }); + }); + _player.stream.position.listen((event) { + if (!_isSeeking) { + setState(() { + _position = event; + }); + } + }); + _player.stream.error.listen((event) { + if (event.toString().isNotEmpty) { + setState(() { + _error = event.toString(); + }); + } + }); + _player.stream.completed.listen((event) { + if (_playerIndex == widget.playList.length - 1) { + if (Platform.isAndroid) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('video.play-complete'.i18n), + )); + return; + } + fluent.displayInfoBar(context, builder: ((context, close) { + return fluent.InfoBar(title: Text('video.play-complete'.i18n)); + })); + return; + } + if (!_isLoading) { + _togglePlayIndex(index: _playerIndex + 1); + } + }); + + super.initState(); + } + + @override + void dispose() { + if (Platform.isAndroid) { + // 切换回竖屏 + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + } + _player.dispose(); + super.dispose(); + } + + _addHistory() async { + if (_position.inSeconds < 1) { + return; + } + final tempDir = await MiruDirectory.getCacheDirectory; + final coverDir = path.join(tempDir, 'history_cover'); + Directory(coverDir).createSync(recursive: true); + final epName = widget.playList[_playerIndex].name; + final filename = '${widget.title}_$epName'; + final file = File(path.join(coverDir, filename)); + if (file.existsSync()) { + file.deleteSync(recursive: true); + } + + final coverPath = await _screenshotController.captureAndSave( + coverDir, + fileName: filename, + ); + await DatabaseUtils.putHistory( + History() + ..url = widget.detailUrl + ..cover = coverPath! + ..episodeGroupId = widget.episodeGroupId + ..package = widget.runtime.extension.package + ..type = widget.runtime.extension.type + ..episodeId = _playerIndex + ..episodeTitle = epName + ..title = widget.title, + ); + await Get.find().onRefresh(); + } + + _play() async { + _isLoading = true; + try { + final playUrl = widget.playList[_playerIndex].url; + final m3u8Url = + (await widget.runtime.watch(playUrl) as ExtensionBangumiWatch).url; + debugPrint(m3u8Url); + _player.open(Media(m3u8Url)); + _player.stream.buffering.listen((event) { + debugPrint(event.toString()); + _isLoading = event; + }); + } catch (e) { + debugPrint(e.toString()); + _error = e.toString(); + } finally { + if (mounted) { + setState(() {}); + } + } + } + + _togglePlayIndex({int index = 0}) async { + setState(() { + _playerIndex = index; + }); + _play(); + } + + // 头部控制面板 + _playerControlPanelHeader() { + return PlatformWidget( + androidWidget: Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + onPressed: () async { + await _addHistory(); + Get.back(); + }, + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Text( + "${widget.title} - ${widget.playList[_playerIndex].name}", + style: const TextStyle( + fontSize: 18, + color: Colors.white, + ), + ) + ], + ), + ), + desktopWidget: Container( + height: 50, + margin: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + clipBehavior: Clip.antiAlias, + child: fluent.Acrylic( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "${widget.title} - ${widget.playList[_playerIndex].name}", + style: const TextStyle(fontSize: 18), + ), + ), + ), + ), + const Spacer(), + // 关闭按钮 + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + clipBehavior: Clip.antiAlias, + child: GestureDetector( + onTap: () async { + await _addHistory(); + await WindowManager.instance.setFullScreen(false); + router.pop(); + }, + child: const fluent.Acrylic( + child: Padding( + padding: EdgeInsets.all(11), + child: Icon( + fluent.FluentIcons.clear, + ), + ), + ), + ), + ) + ], + ), + ), + ); + } + + // 底部控制面板 + _playerControlPanel() { + final content = PlatformWidget( + androidWidget: Container( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + IconButton( + icon: const Icon( + Icons.skip_previous, + color: Colors.white, + ), + onPressed: () { + if (_playerIndex == 0) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('video.already-first'.i18n), + )); + return; + } + _togglePlayIndex(index: _playerIndex - 1); + }, + ), + + // 暂停播放按钮 + IconButton( + icon: Icon( + _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_fill, + color: Colors.white, + ), + onPressed: () { + _player.playOrPause(); + }, + ), + IconButton( + icon: const Icon( + Icons.skip_next, + color: Colors.white, + ), + onPressed: () { + if (_playerIndex == widget.playList.length - 1) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('video.already-last'.i18n), + )); + return; + } + _togglePlayIndex(index: _playerIndex + 1); + }, + ), + Expanded( + child: Slider( + label: _position.toString().split('.')[0], + value: _position.inMicroseconds.toDouble(), + max: _duration.inMicroseconds.toDouble(), + onChangeEnd: (value) { + _player.seek(_position); + setState(() { + _isSeeking = false; + }); + }, + onChangeStart: (value) { + setState(() { + _isSeeking = true; + }); + }, + onChanged: (double value) { + setState(() { + _position = Duration(microseconds: value.toInt()); + }); + }, + ), + ), + const SizedBox(width: 8), + // 进度指示器 + Text( + '${_position.toString().split('.')[0]} / ${_duration.toString().split('.')[0]}', + style: const TextStyle( + fontSize: 12, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + setState(() { + if (_openSidebar) { + _showPlayList = false; + } + _openSidebar = !_openSidebar; + }); + }, + icon: const Icon( + Icons.list, + color: Colors.white, + ), + ) + ], + ), + ), + desktopWidget: Row( + children: [ + fluent.IconButton( + icon: const Icon(fluent.FluentIcons.previous), + onPressed: () { + if (_playerIndex == 0) { + fluent.displayInfoBar(context, builder: ((context, close) { + return fluent.InfoBar( + title: Text('video.already-first'.i18n)); + })); + return; + } + _togglePlayIndex(index: _playerIndex - 1); + }, + ), + fluent.IconButton( + icon: Icon(_isPlaying + ? fluent.FluentIcons.pause + : fluent.FluentIcons.play), + onPressed: () { + _player.playOrPause(); + }, + ), + fluent.IconButton( + icon: const Icon(fluent.FluentIcons.next), + onPressed: () { + if (_playerIndex == widget.playList.length - 1) { + fluent.displayInfoBar(context, builder: ((context, close) { + return fluent.InfoBar(title: Text('video.already-last'.i18n)); + })); + return; + } + _togglePlayIndex(index: _playerIndex + 1); + }, + ), + const SizedBox(width: 8), + Text( + _position.toString().split('.')[0], + style: const TextStyle(fontSize: 12), + ), + const SizedBox(width: 8), + Expanded( + child: fluent.Slider( + label: _position.toString().split('.')[0], + value: _position.inMicroseconds.toDouble(), + max: _duration.inMicroseconds.toDouble(), + onChangeEnd: (value) { + _player.seek(_position); + setState(() { + _isSeeking = false; + }); + }, + onChangeStart: (value) { + setState(() { + _isSeeking = true; + }); + }, + onChanged: (double value) { + setState(() { + _position = Duration(microseconds: value.toInt()); + }); + }, + ), + ), + const SizedBox(width: 8), + Text( + _duration.toString().split('.')[0], + style: const TextStyle(fontSize: 12), + ), + const SizedBox(width: 8), + fluent.IconButton( + icon: Icon(_isFullScreen + ? fluent.FluentIcons.back_to_window + : fluent.FluentIcons.full_screen), + onPressed: () async { + await WindowManager.instance.setFullScreen(!_isFullScreen); + setState(() { + _isFullScreen = !_isFullScreen; + }); + }), + fluent.IconButton( + icon: const Icon(fluent.FluentIcons.bulleted_list), + onPressed: () { + setState(() { + if (_openSidebar) { + _showPlayList = false; + } + _openSidebar = !_openSidebar; + }); + }), + fluent.IconButton( + icon: const Icon(fluent.FluentIcons.more), onPressed: () {}) + ], + ), + ); + + return PlatformWidget( + androidWidget: content, + desktopWidget: Container( + height: 50, + margin: const EdgeInsets.symmetric( + horizontal: 40, + vertical: 40, + ), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + clipBehavior: Clip.antiAlias, + width: double.infinity, + child: fluent.Acrylic( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: content, + ), + ), + ), + ); + } + +// 中间控制面板 + _playerControlPanelCenter() { + if (_error.isNotEmpty) { + return SizedBox.expand( + child: Center( + child: Text( + _error, + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + if (_isLoading) { + return const Center( + child: ProgressRing(), + ); + } + return const SizedBox.expand(); + } + + @override + Widget build(BuildContext context) { + final content = WillPopScope( + onWillPop: () async { + await _addHistory(); + debugPrint("onWillPop"); + return true; + }, + child: LayoutBuilder(builder: (context, container) { + return Row( + children: [ + AnimatedContainer( + width: + _openSidebar ? container.maxWidth - 300 : container.maxWidth, + duration: const Duration(milliseconds: 120), + curve: Curves.ease, + onEnd: () { + setState(() { + if (_openSidebar) { + _showPlayList = true; + } + }); + }, + child: Stack( + children: [ + Screenshot( + controller: _screenshotController, + child: Video( + controller: _controller, + controls: (state) => const SizedBox.shrink(), + ), + ), + Positioned.fill( + child: MouseRegion( + onHover: (event) { + if (!_showControl) { + setState(() { + _showControl = true; + }); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + setState(() { + _showControl = false; + }); + } + }); + } + }, + child: Column( + children: [ + if (_showControl || _isLoading || !_isPlaying) + _playerControlPanelHeader(), + Expanded( + child: _isFullScreen + ? _playerControlPanelCenter() + : DragToMoveArea( + child: _playerControlPanelCenter(), + ), + ), + if (_showControl || _isLoading || !_isPlaying) + _playerControlPanel() + ], + ), + ), + ), + ], + ), + ), + // 播放列表 + if (_showPlayList) + Expanded( + child: p.PlayList( + selectIndex: _playerIndex, + list: widget.playList.map((e) => e.name).toList(), + title: widget.title, + onChange: (value) { + _togglePlayIndex(index: value); + }, + ), + ) + ], + ); + }), + ); + return PlatformWidget( + androidWidget: Scaffold( + body: content, + ), + desktopWidget: content, + ); + } +} diff --git a/lib/pages/watch/widgets/video/video_player_content.dart b/lib/pages/watch/widgets/video/video_player_content.dart new file mode 100644 index 00000000..5969d5c6 --- /dev/null +++ b/lib/pages/watch/widgets/video/video_player_content.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:miru_app/pages/watch/video_controller.dart'; +import 'package:miru_app/utils/router.dart'; +import 'package:miru_app/widgets/platform_widget.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:window_manager/window_manager.dart'; + +class VideoPlayerConten extends StatefulWidget { + const VideoPlayerConten({ + Key? key, + required this.tag, + }) : super(key: key); + final String tag; + + @override + State createState() => _VideoPlayerContenState(); +} + +class _VideoPlayerContenState extends State { + late final _c = Get.find(tag: widget.tag); + + Widget _buildDesktop(BuildContext context) { + return MaterialDesktopVideoControlsTheme( + normal: MaterialDesktopVideoControlsThemeData( + toggleFullscreenOnDoublePress: false, + topButtonBar: [ + Expanded( + child: DragToMoveArea( + child: Row( + children: [ + Obx( + () => Text( + "${_c.title} - ${_c.playList[_c.index.value].name}", + style: const TextStyle( + color: Colors.white, + fontSize: 20, + ), + ), + ), + const Spacer(), + MaterialDesktopCustomButton( + icon: const Icon( + Icons.keyboard_arrow_down, + color: Colors.white, + ), + onPressed: () async { + await _c.addHistory(); + RouterUtils.pop(); + }, + ), + ], + ), + ), + ) + ], + bottomButtonBar: [ + Obx(() { + if (_c.index.value > 0) { + return MaterialDesktopCustomButton( + icon: const Icon(Icons.skip_previous), + onPressed: () { + _c.index.value--; + }, + ); + } + return const SizedBox.shrink(); + }), + const MaterialDesktopPlayOrPauseButton(), + Obx(() { + if (_c.index.value != _c.playList.length - 1) { + return MaterialDesktopCustomButton( + icon: const Icon(Icons.skip_next), + onPressed: () { + _c.index.value++; + }, + ); + } + return const SizedBox.shrink(); + }), + const MaterialDesktopVolumeButton(), + const MaterialDesktopPositionIndicator(), + const Spacer(), + MaterialDesktopCustomButton( + onPressed: () { + _c.showPlayList.value = !_c.showPlayList.value; + }, + icon: const Icon(Icons.list), + ), + Obx( + () => MaterialDesktopCustomButton( + onPressed: () => _c.toggleFullscreen(), + icon: (_c.isFullScreen.value + ? const Icon(Icons.fullscreen_exit) + : const Icon(Icons.fullscreen)), + ), + ) + ], + ), + fullscreen: const MaterialDesktopVideoControlsThemeData(), + child: Screenshot( + controller: _c.screenshotController, + child: Obx( + () => Video( + controller: _c.videoController, + controls: _c.hideControlPanel.value ? null : AdaptiveVideoControls, + ), + ), + ), + ); + } + + Widget _buildAndroid(BuildContext context) { + return MaterialVideoControlsTheme( + normal: MaterialVideoControlsThemeData( + topButtonBar: [ + Obx( + () => Text( + "${_c.title} - ${_c.playList[_c.index.value].name}", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), + ), + const Spacer(), + MaterialCustomButton( + icon: const Icon( + Icons.keyboard_arrow_down, + color: Colors.white, + ), + onPressed: () async { + await _c.addHistory(); + RouterUtils.pop(); + }, + ), + ], + bottomButtonBar: [ + Obx(() { + if (_c.index.value > 0) { + return MaterialCustomButton( + icon: const Icon(Icons.skip_previous), + onPressed: () { + _c.index.value--; + }, + ); + } + return const SizedBox.shrink(); + }), + const MaterialPlayOrPauseButton(), + Obx(() { + if (_c.index.value != _c.playList.length - 1) { + return MaterialCustomButton( + icon: const Icon(Icons.skip_next), + onPressed: () { + _c.index.value++; + }, + ); + } + return const SizedBox.shrink(); + }), + const MaterialPositionIndicator(), + const Spacer(), + MaterialCustomButton( + onPressed: () { + _c.showPlayList.value = !_c.showPlayList.value; + }, + icon: const Icon(Icons.list), + ), + ], + seekBarMargin: const EdgeInsets.only(bottom: 60, left: 16, right: 16), + ), + fullscreen: const MaterialVideoControlsThemeData(), + child: Screenshot( + controller: _c.screenshotController, + child: Obx( + () => Video( + controller: _c.videoController, + controls: _c.hideControlPanel.value ? null : AdaptiveVideoControls, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +} diff --git a/lib/utils/database.dart b/lib/utils/database.dart index f3f0a9a0..dc902da0 100644 --- a/lib/utils/database.dart +++ b/lib/utils/database.dart @@ -97,7 +97,9 @@ class DatabaseUtils { ..title = history.title ..episodeGroupId = history.episodeGroupId ..episodeId = history.episodeId - ..episodeTitle = history.episodeTitle; + ..episodeTitle = history.episodeTitle + ..progress = history.progress + ..totalProgress = history.totalProgress; return db.writeTxn(() => db.historys.put(hst)); } diff --git a/pubspec.yaml b/pubspec.yaml index b8783d8b..9d904ff5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: miru_app description: A new Flutter project. publish_to: "none" -version: 1.4.1+13 +version: 1.4.2+14 environment: sdk: ">=3.0.3 <4.0.0"