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"