From 61ef36e2180102fe3f987357ccec87d42f75ee5a Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 27 Jan 2024 12:34:48 +0800 Subject: [PATCH 01/19] Refactor video player --- lib/controllers/watch/video_controller.dart | 330 +++--- lib/views/pages/watch/video/video_player.dart | 12 +- .../watch/video/video_player_content.dart | 374 ++----- .../watch/video/video_player_controls.dart | 961 ++++++++++++++++++ .../watch/video/video_player_sidebar.dart | 90 ++ lib/views/widgets/watch/playlist.dart | 31 +- 6 files changed, 1299 insertions(+), 499 deletions(-) create mode 100644 lib/views/pages/watch/video/video_player_controls.dart create mode 100644 lib/views/pages/watch/video/video_player_sidebar.dart diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 81bbad86..97491135 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -3,20 +3,19 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_i18n/flutter_i18n.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/data/providers/anilist_provider.dart'; import 'package:miru_app/data/providers/bt_server_provider.dart'; import 'package:miru_app/models/index.dart'; +import 'package:miru_app/utils/log.dart'; import 'package:miru_app/utils/request.dart'; +import 'package:miru_app/utils/router.dart'; import 'package:miru_app/views/dialogs/bt_dialog.dart'; import 'package:miru_app/controllers/home_controller.dart'; import 'package:miru_app/controllers/main_controller.dart'; @@ -59,27 +58,88 @@ class VideoPlayerController extends GetxController { final isOpenSidebar = false.obs; final isFullScreen = false.obs; late final index = playIndex.obs; - final subtitles = [].obs; - final keyboardShortcuts = {}; - final selectedSubtitle = 0.obs; + + // 快捷键 + late final keyboardShortcuts = { + LogicalKeyboardKey.escape: () { + if (isFullScreen.value) { + WindowManager.instance.setFullScreen(false); + } + RouterUtils.pop(); + }, + LogicalKeyboardKey.keyF: () => toggleFullscreen(), + LogicalKeyboardKey.mediaPlay: () => player.play(), + LogicalKeyboardKey.mediaPause: () => player.pause(), + LogicalKeyboardKey.mediaPlayPause: () => player.playOrPause(), + LogicalKeyboardKey.mediaTrackNext: () => player.next(), + LogicalKeyboardKey.mediaTrackPrevious: () => player.previous(), + LogicalKeyboardKey.space: () => player.playOrPause(), + LogicalKeyboardKey.keyJ: () { + final rate = player.state.position + + Duration( + milliseconds: + (MiruStorage.getSetting(SettingKey.keyJ) * 1000).toInt(), + ); + player.seek(rate); + }, + LogicalKeyboardKey.keyI: () { + final rate = player.state.position + + Duration( + milliseconds: + (MiruStorage.getSetting(SettingKey.keyI) * 1000).toInt()); + player.seek(rate); + }, + LogicalKeyboardKey.arrowLeft: () { + final rate = player.state.position + + Duration( + milliseconds: + (MiruStorage.getSetting(SettingKey.arrowLeft) * 1000) + .toInt()); + player.seek(rate); + }, + LogicalKeyboardKey.arrowRight: () { + final rate = player.state.position + + Duration( + milliseconds: + (MiruStorage.getSetting(SettingKey.arrowRight) * 1000) + .toInt()); + player.seek(rate); + }, + LogicalKeyboardKey.arrowUp: () { + final volume = player.state.volume + 5.0; + player.setVolume(volume.clamp(0.0, 100.0)); + }, + LogicalKeyboardKey.arrowDown: () { + final volume = player.state.volume - 5.0; + player.setVolume(volume.clamp(0.0, 100.0)); + }, + }; + + // 字幕 + final subtitles = [].obs; + + // 画质 final currentQality = "".obs; - final qualityUrls = {}; + final qualityMap = {}; + // 是否已经自动跳转到上次播放进度 bool _isAutoSeekPosition = false; - Map? videoheaders = {}; - final messageQueue = []; + // 信息列队 + final messageQueue = []; final Rx cuurentMessageWidget = Rx(null); - final speed = 1.0.obs; + // 播放速度 + final currentSpeed = 1.0.obs; + final speedList = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; + // torrent 媒体文件 final torrentMediaFileList = [].obs; final currentTorrentFile = ''.obs; - String _torrenHash = ""; - final ReceivePort qualityRereceivePort = ReceivePort(); - Isolate? qualityReceiver; - // 复制当前 context + + // 调用 watch 方法获取到的数据 + final Rx watchData = Rx(null); @override void onInit() async { @@ -105,7 +165,7 @@ class VideoPlayerController extends GetxController { }); // 切换倍速 - ever(speed, (callback) { + ever(currentSpeed, (callback) { player.setRate(callback); }); @@ -115,55 +175,6 @@ class VideoPlayerController extends GetxController { isOpenSidebar.value = false; } }); - // 切换字幕 - ever(selectedSubtitle, (callback) { - if (callback == -1) { - player.setSubtitleTrack(SubtitleTrack.no()); - return; - } - if (callback == -2) { - // 选择文件 srt 或者 vtt - FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['srt', 'vtt'], - ).then((value) { - if (value == null) { - selectedSubtitle.value = -1; - return; - } - - // 读取文件 - final data = File(value.files.first.path!).readAsStringSync(); - player.setSubtitleTrack(SubtitleTrack.data(data)); - sendMessage( - Message( - Text( - FlutterI18n.translate( - currentContext, - "video.subtitle-change", - translationParams: {"title": value.files.first.name}, - ), - ), - ), - ); - }); - return; - } - player.setSubtitleTrack( - SubtitleTrack.uri(subtitles[callback].url), - ); - sendMessage( - Message( - Text( - FlutterI18n.translate( - currentContext, - "video.subtitle-change", - translationParams: {"title": subtitles[callback].title}, - ), - ), - ), - ); - }); // 自动切换下一集 player.stream.completed.listen((event) { @@ -178,15 +189,7 @@ class VideoPlayerController extends GetxController { index.value++; } }); - //畫質的listener - qualityRereceivePort.listen((message) async { - debugPrint("${message.keys} get"); - final resolution = message['resolution']; - final urls = message['urls']; - qualityUrls.addAll(Map.fromIterables(resolution, urls)); - qualityRereceivePort.close(); - qualityReceiver!.kill(); - }); + //讀取現在的畫質 player.stream.height.listen((event) async { if (player.state.width != null) { @@ -194,6 +197,7 @@ class VideoPlayerController extends GetxController { currentQality.value = "${width}x$event"; } }); + // 自动恢复上次播放进度 player.stream.duration.listen((event) async { if (_isAutoSeekPosition || event.inSeconds == 0) { @@ -222,73 +226,22 @@ class VideoPlayerController extends GetxController { }); super.onInit(); - keyboardShortcuts.addAll({ - const SingleActivator(LogicalKeyboardKey.mediaPlay): () => player.play(), - const SingleActivator(LogicalKeyboardKey.mediaPause): () => - player.pause(), - const SingleActivator(LogicalKeyboardKey.mediaPlayPause): () => - player.playOrPause(), - const SingleActivator(LogicalKeyboardKey.mediaTrackNext): () => - player.next(), - const SingleActivator(LogicalKeyboardKey.mediaTrackPrevious): () => - player.previous(), - const SingleActivator(LogicalKeyboardKey.space): () => - player.playOrPause(), - const SingleActivator(LogicalKeyboardKey.keyJ): () { - final rate = player.state.position + - Duration( - milliseconds: - (MiruStorage.getSetting(SettingKey.keyJ) * 1000).toInt()); - player.seek(rate); - }, - const SingleActivator(LogicalKeyboardKey.keyI): () { - final rate = player.state.position + - Duration( - milliseconds: - (MiruStorage.getSetting(SettingKey.keyI) * 1000).toInt()); - player.seek(rate); - }, - const SingleActivator(LogicalKeyboardKey.arrowLeft): () { - final rate = player.state.position + - Duration( - milliseconds: - (MiruStorage.getSetting(SettingKey.arrowLeft) * 1000) - .toInt()); - player.seek(rate); - }, - const SingleActivator(LogicalKeyboardKey.arrowRight): () { - final rate = player.state.position + - Duration( - milliseconds: - (MiruStorage.getSetting(SettingKey.arrowRight) * 1000) - .toInt()); - player.seek(rate); - }, - const SingleActivator(LogicalKeyboardKey.arrowUp): () { - final volume = player.state.volume + 5.0; - player.setVolume(volume.clamp(0.0, 100.0)); - }, - const SingleActivator(LogicalKeyboardKey.arrowDown): () { - final volume = player.state.volume - 5.0; - player.setVolume(volume.clamp(0.0, 100.0)); - }, - }); } play() async { + player.stop(); // 如果已经 delete 当前 controller if (!Get.isRegistered(tag: title)) { return; } try { + watchData.value = null; subtitles.clear(); - selectedSubtitle.value = -1; final playUrl = playList[index.value].url; - final watchData = await runtime.watch(playUrl) as ExtensionBangumiWatch; - videoheaders = watchData.headers; + watchData.value = await runtime.watch(playUrl) as ExtensionBangumiWatch; - if (watchData.type == ExtensionWatchBangumiType.torrent) { + if (watchData.value!.type == ExtensionWatchBangumiType.torrent) { if (Get.find().btServerisRunning.value == false) { await BTServerUtils.startServer(); } @@ -302,7 +255,7 @@ class VideoPlayerController extends GetxController { MiruDirectory.getCacheDirectory, 'temp.torrent', ); - await dio.download(watchData.url, torrentFile); + await dio.download(watchData.value!.url, torrentFile); final file = File(torrentFile); _torrenHash = await BTServerApi.addTorrent(file.readAsBytesSync()); @@ -313,46 +266,42 @@ class VideoPlayerController extends GetxController { for (final file in files) { if (_isSubtitle(file)) { subtitles.add( - ExtensionBangumiWatchSubtitle( + SubtitleTrack.uri( + '${BTServerApi.baseApi}/torrent/$_torrenHash/$file', title: path.basename(file), - url: '${BTServerApi.baseApi}/torrent/$_torrenHash/$file', ), ); } else { torrentMediaFileList.add(file); } } + playTorrentFile(torrentMediaFileList.first); } else { - //背景取得畫質 - qualityReceiver = await Isolate.spawn((SendPort sendport) async { - try { - final response = await dio.get( - watchData.url, - options: Options( - headers: watchData.headers, - ), - ); - final playList = await HlsPlaylistParser.create().parseString( - Uri.parse(watchData.url), response.data) as HlsMasterPlaylist; - List urlList = - playList.mediaPlaylistUrls.map((e) => e.toString()).toList(); - final resolution = playList.variants - .map((it) => "${it.format.width}x${it.format.height}"); - debugPrint("get sources"); - sendport.send({'resolution': resolution, 'urls': urlList}); - } catch (error) { - debugPrint('Error: $error'); - } - }, qualityRereceivePort.sendPort); - - await player.open(Media(watchData.url, httpHeaders: watchData.headers)); - if (watchData.audioTrack != null) { - await player.setAudioTrack(AudioTrack.uri(watchData.audioTrack!)); + getQuality(); + await player.open( + Media(watchData.value!.url, httpHeaders: watchData.value!.headers), + ); + if (watchData.value!.audioTrack != null) { + await player.setAudioTrack( + AudioTrack.uri(watchData.value!.audioTrack!), + ); } } - subtitles.addAll(watchData.subtitles ?? []); + + // 添加来自扩展的字幕 + subtitles.addAll( + (watchData.value!.subtitles ?? []).map( + (e) => SubtitleTrack.uri( + e.url, + language: e.language, + title: e.title, + ), + ), + ); + player.setSubtitleTrack(SubtitleTrack.no()); } catch (e) { + // 如果是 启动 bt server 失败 if (e is StartServerException) { if (Platform.isAndroid) { await showDialog( @@ -368,9 +317,7 @@ class VideoPlayerController extends GetxController { // 延时 3 秒再重试 await Future.delayed(const Duration(seconds: 3)); - play(); - return; } sendMessage( @@ -383,6 +330,31 @@ class VideoPlayerController extends GetxController { } } + getQuality() async { + final url = watchData.value!.url; + final headers = watchData.value!.headers; + try { + final response = await dio.get( + url, + options: Options( + headers: headers, + ), + ); + final playList = await HlsPlaylistParser.create().parseString( + Uri.parse(url), + response.data, + ) as HlsMasterPlaylist; + List urlList = + playList.mediaPlaylistUrls.map((e) => e.toString()).toList(); + final resolution = playList.variants + .map((it) => "${it.format.width}x${it.format.height}"); + logger.info("get sources"); + qualityMap.addAll(Map.fromIterables(resolution, urlList)); + } catch (error) { + logger.severe(error); + } + } + playTorrentFile(String file) { currentTorrentFile.value = file; (player.platform as NativePlayer).setProperty("network-timeout", "60"); @@ -396,19 +368,17 @@ class VideoPlayerController extends GetxController { switchQuality(String qualityUrl) async { final currentSecond = player.state.position.inSeconds; - try { - await player.open(Media(qualityUrl, httpHeaders: videoheaders)); - //跳轉到切換之前的時間 - Timer.periodic(const Duration(seconds: 1), (timer) { - player.seek(Duration(seconds: currentSecond)); - if (player.state.position.inSeconds == currentSecond) { - timer.cancel(); - } - }); - } catch (e) { - await Future.delayed(const Duration(seconds: 3)); - player.open(Media(qualityUrl, httpHeaders: videoheaders)); - } + final headers = watchData.value!.headers; + await player.open( + Media(qualityUrl, httpHeaders: headers), + ); + //跳轉到切換之前的時間 + Timer.periodic(const Duration(seconds: 1), (timer) { + player.seek(Duration(seconds: currentSecond)); + if (player.state.position.inSeconds == currentSecond) { + timer.cancel(); + } + }); } onExit() async { @@ -484,6 +454,14 @@ class VideoPlayerController extends GetxController { @override void onClose() { + if (MiruStorage.getSetting(SettingKey.autoTracking) && anilistID != "") { + AniListProvider.editList( + status: AnilistMediaListStatus.current, + progress: playIndex + 1, + mediaId: anilistID, + ); + } + if (Platform.isAndroid) { SystemChrome.setEnabledSystemUIMode( SystemUiMode.edgeToEdge, @@ -499,14 +477,6 @@ class VideoPlayerController extends GetxController { ]); } - if (MiruStorage.getSetting(SettingKey.autoTracking) && anilistID != "") { - AniListProvider.editList( - status: AnilistMediaListStatus.current, - progress: playIndex + 1, - mediaId: anilistID, - ); - } - super.onClose(); } } diff --git a/lib/views/pages/watch/video/video_player.dart b/lib/views/pages/watch/video/video_player.dart index 86e39e65..20c40e18 100644 --- a/lib/views/pages/watch/video/video_player.dart +++ b/lib/views/pages/watch/video/video_player.dart @@ -3,7 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:miru_app/models/extension.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; -import 'package:miru_app/views/widgets/watch/playlist.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:miru_app/views/pages/watch/video/video_player_content.dart'; import 'package:miru_app/data/services/extension_service.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; @@ -110,14 +110,8 @@ class _VideoPlayerState extends State { ), if (_c.isOpenSidebar.value) Expanded( - child: PlayList( - selectIndex: _c.index.value, - list: widget.playList.map((e) => e.name).toList(), - title: widget.title, - onChange: (value) { - _c.index.value = value; - _c.showPlayList.value = false; - }, + child: VideoPlayerSidebar( + controller: _c, ), ) ], diff --git a/lib/views/pages/watch/video/video_player_content.dart b/lib/views/pages/watch/video/video_player_content.dart index 18c5d9cd..e3acee8b 100644 --- a/lib/views/pages/watch/video/video_player_content.dart +++ b/lib/views/pages/watch/video/video_player_content.dart @@ -6,6 +6,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/utils/router.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_controls.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:window_manager/window_manager.dart'; @@ -72,230 +73,13 @@ class _VideoPlayerContenState extends State { ); Widget _buildDesktop(BuildContext context) { - return MaterialDesktopVideoControlsTheme( - normal: MaterialDesktopVideoControlsThemeData( - toggleFullscreenOnDoublePress: false, - keyboardShortcuts: _c.keyboardShortcuts, - topButtonBar: [ - Obx( - () => Expanded( - child: _c.isFullScreen.value - ? topButtonBar - : DragToMoveArea( - child: topButtonBar, - ), - ), - ) - ], - bottomButtonBar: [ - Obx(() { - if (_c.index.value > 0) { - return Tooltip( - message: "video.tooltip.previous".i18n, - child: MaterialDesktopCustomButton( - icon: const Icon(Icons.skip_previous), - onPressed: () { - _c.index.value--; - }, - ), - ); - } - return const SizedBox.shrink(); - }), - Tooltip( - message: "video.tooltip.play-or-pause".i18n, - child: const MaterialDesktopPlayOrPauseButton(), - ), - Obx(() { - if (_c.index.value != _c.playList.length - 1) { - return Tooltip( - message: "video.tooltip.next".i18n, - child: MaterialDesktopCustomButton( - icon: const Icon(Icons.skip_next), - onPressed: () { - _c.index.value++; - }, - ), - ); - } - return const SizedBox.shrink(); - }), - Tooltip( - message: "video.tooltip.volume".i18n, - child: const MaterialDesktopVolumeButton(), - ), - const MaterialDesktopPositionIndicator(), - const Spacer(), - Theme( - data: ThemeData.dark(useMaterial3: true), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: PopupMenuButton( - tooltip: "video.tooltip.speed".i18n, - child: Obx( - () => Text( - 'x${_c.speed.value}', - style: const TextStyle(color: Colors.white), - ), - ), - itemBuilder: (context) { - return [ - for (final speed in speeds) - PopupMenuItem( - child: Text('x$speed'), - onTap: () { - _c.speed.value = speed; - }, - ), - ]; - }, - ), - ), - Obx(() { - if (_c.torrentMediaFileList.isEmpty) { - return const SizedBox.shrink(); - } - return PopupMenuButton( - tooltip: "video.tooltip.torrent-file-list".i18n, - icon: const Icon( - Icons.file_open, - color: Colors.white, - ), - itemBuilder: (context) { - return [ - for (int i = 0; i < _c.torrentMediaFileList.length; i++) - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.currentTorrentFile.value == - _c.torrentMediaFileList[i], - onChanged: (_) { - _c.playTorrentFile( - _c.torrentMediaFileList[i], - ); - }, - title: Text(_c.torrentMediaFileList[i]), - ), - ), - ), - ]; - }, - ); - }), - PopupMenuButton( - tooltip: "video.tooltip.subtitle".i18n, - icon: const Icon( - Icons.subtitles, - color: Colors.white, - ), - itemBuilder: (context) { - return [ - // 是否显示字幕 - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == -1, - onChanged: (value) { - _c.selectedSubtitle.value = -1; - }, - title: Text('video.subtitle-none'.i18n), - ), - ), - ), - // 选择文件 - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == -2, - onChanged: (value) { - _c.selectedSubtitle.value = -2; - }, - title: Text("video.subtitle-file".i18n), - ), - ), - ), - for (int i = 0; i < _c.subtitles.length; i++) - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == i, - onChanged: (value) { - _c.selectedSubtitle.value = i; - }, - title: Text(_c.subtitles[i].title), - ), - ), - ), - ]; - }, - ), - Obx(() { - if (_c.currentQality.value.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: PopupMenuButton( - tooltip: "video.tooltip.quality".i18n, - child: Obx( - () => Text( - _c.currentQality.value, - style: const TextStyle(color: Colors.white), - ), - ), - itemBuilder: (context) { - return [ - for (final qualit in _c.qualityUrls.entries) - PopupMenuItem( - child: Text(qualit.key), - onTap: () { - _c.switchQuality(_c.qualityUrls[qualit.key]!); - }, - ), - ]; - }, - ), - ); - }) - ], - ), - ), - Tooltip( - message: "video.tooltip.play-list".i18n, - child: MaterialDesktopCustomButton( - onPressed: () { - _c.showPlayList.value = !_c.showPlayList.value; - }, - icon: const Icon(Icons.list), - ), - ), - Obx( - () => Tooltip( - message: "video.tooltip.full-screen".i18n, - child: MaterialDesktopCustomButton( - onPressed: () => _c.toggleFullscreen(), - icon: (_c.isFullScreen.value - ? const Icon(Icons.fullscreen_exit) - : const Icon(Icons.fullscreen)), - ), - ), - ) - ], - ), - fullscreen: const MaterialDesktopVideoControlsThemeData(), - child: Video( - controller: _c.videoController, - ), + return Video( + controller: _c.videoController, + controls: (state) { + return VideoPlayerControls( + tag: widget.tag, + ); + }, ); } @@ -305,6 +89,10 @@ class _VideoPlayerContenState extends State { volumeGesture: true, brightnessGesture: true, topButtonBar: [Expanded(child: topButtonBar)], + topButtonBarMargin: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 20, + ), bottomButtonBar: [ Obx(() { if (_c.index.value > 0) { @@ -340,7 +128,7 @@ class _VideoPlayerContenState extends State { child: PopupMenuButton( child: Obx( () => Text( - 'x${_c.speed.value}', + 'x${_c.currentSpeed.value}', style: const TextStyle(color: Colors.white), ), ), @@ -350,7 +138,7 @@ class _VideoPlayerContenState extends State { PopupMenuItem( child: Text('x$speed'), onTap: () { - _c.speed.value = speed; + _c.currentSpeed.value = speed; }, ), ]; @@ -397,77 +185,77 @@ class _VideoPlayerContenState extends State { itemBuilder: (context) { return [ // 是否显示字幕 - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == -1, - onChanged: (value) { - _c.selectedSubtitle.value = -1; - }, - title: Text('video.subtitle-none'.i18n), - ), - ), - ), + // PopupMenuItem( + // padding: const EdgeInsets.symmetric( + // horizontal: 10, vertical: 0), + // child: Obx( + // () => CheckboxListTile( + // value: _c.selectedSubtitle.value == -1, + // onChanged: (value) { + // _c.selectedSubtitle.value = -1; + // }, + // title: Text('video.subtitle-none'.i18n), + // ), + // ), + // ), // 选择文件 - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == -2, - onChanged: (value) { - _c.selectedSubtitle.value = -2; - }, - title: Text("video.subtitle-file".i18n), - ), - ), - ), - for (int i = 0; i < _c.subtitles.length; i++) - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.selectedSubtitle.value == i, - onChanged: (value) { - _c.selectedSubtitle.value = i; - }, - title: Text(_c.subtitles[i].title), - ), - ), - ), + // PopupMenuItem( + // padding: const EdgeInsets.symmetric( + // horizontal: 10, vertical: 0), + // child: Obx( + // () => CheckboxListTile( + // value: _c.selectedSubtitle.value == -2, + // onChanged: (value) { + // _c.selectedSubtitle.value = -2; + // }, + // title: Text("video.subtitle-file".i18n), + // ), + // ), + // ), + // for (int i = 0; i < _c.subtitles.length; i++) + // PopupMenuItem( + // padding: const EdgeInsets.symmetric( + // horizontal: 10, vertical: 0), + // child: Obx( + // () => CheckboxListTile( + // value: _c.selectedSubtitle.value == i, + // onChanged: (value) { + // _c.selectedSubtitle.value = i; + // }, + // title: Text(_c.subtitles[i].title), + // ), + // ), + // ), ]; }, ), - Obx(() { - if (_c.currentQality.value.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: PopupMenuButton( - child: Obx( - () => Text( - _c.currentQality.value, - style: const TextStyle(color: Colors.white), - ), - ), - itemBuilder: (context) { - return [ - for (final qualit in _c.qualityUrls.entries) - PopupMenuItem( - child: Text(qualit.key), - onTap: () { - _c.switchQuality(_c.qualityUrls[qualit.key]!); - }, - ), - ]; - }, - ), - ); - }), + // Obx(() { + // if (_c.currentQality.value.isEmpty) { + // return const SizedBox.shrink(); + // } + // return Padding( + // padding: const EdgeInsets.symmetric(horizontal: 8.0), + // child: PopupMenuButton( + // child: Obx( + // () => Text( + // _c.currentQality.value, + // style: const TextStyle(color: Colors.white), + // ), + // ), + // itemBuilder: (context) { + // return [ + // for (final qualit in _c.qualityUrls.entries) + // PopupMenuItem( + // child: Text(qualit.key), + // onTap: () { + // _c.switchQuality(_c.qualityUrls[qualit.key]!); + // }, + // ), + // ]; + // }, + // ), + // ); + // }), ], ), ), diff --git a/lib/views/pages/watch/video/video_player_controls.dart b/lib/views/pages/watch/video/video_player_controls.dart new file mode 100644 index 00000000..0b551b15 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_controls.dart @@ -0,0 +1,961 @@ +import 'dart:async'; + +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:miru_app/controllers/watch/video_controller.dart'; +import 'package:miru_app/router/router.dart'; +import 'package:miru_app/views/widgets/watch/playlist.dart'; +import 'package:window_manager/window_manager.dart'; + +class VideoPlayerControls extends StatefulWidget { + const VideoPlayerControls({ + super.key, + required this.tag, + }); + final String tag; + + @override + State createState() => _VideoPlayerControlsState(); +} + +class _VideoPlayerControlsState extends State { + late final c = Get.find(tag: widget.tag); + final FocusNode _focusNode = FocusNode(); + Timer? _timer; + bool showControls = true; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) { + if (mounted) { + setState(() { + showControls = false; + }); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onHover: (_) { + _timer?.cancel(); + _timer = null; + setState(() { + showControls = true; + }); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) { + if (mounted) { + setState(() { + showControls = false; + }); + } + }, + ); + }, + child: FluentTheme( + data: FluentThemeData( + brightness: Brightness.dark, + ), + child: KeyboardListener( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (value) { + if (value is KeyDownEvent) { + c.keyboardShortcuts[value.logicalKey]?.call(); + } + }, + child: Column( + children: [ + Opacity( + opacity: showControls ? 1 : 0, + child: _VideoPlayerControlsHeader( + title: c.title, + episode: c.playList[c.index.value].name, + onClose: () { + if (c.isFullScreen.value) { + WindowManager.instance.setFullScreen(false); + } + router.pop(); + }, + ), + ), + Expanded( + child: Center( + child: Obx(() { + if (c.watchData.value != null) { + return StreamBuilder( + stream: c.player.stream.buffering, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! || + c.player.state.buffering) { + return const ProgressRing(); + } + return const SizedBox.shrink(); + }, + ); + } + return Text( + 'Getting play link from ${c.runtime.extension.name}...', + ); + }), + ), + ), + Opacity( + opacity: showControls ? 1 : 0, + child: _VideoPlayerFooter(controller: c), + ), + ], + ), + ), + ), + ); + } +} + +class _VideoPlayerControlsHeader extends StatelessWidget { + const _VideoPlayerControlsHeader({ + required this.title, + required this.episode, + required this.onClose, + }); + final String title; + final String episode; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.5), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Expanded( + child: DragToMoveArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + episode, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon( + FluentIcons.chevron_down, + ), + ), + ], + ), + ), + ); + } +} + +class _VideoPlayerFooter extends StatelessWidget { + const _VideoPlayerFooter({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.5), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Column( + children: [ + Row( + children: [ + // 当前进度 + StreamBuilder( + stream: controller.player.stream.position, + builder: (context, snapshot) { + if (snapshot.hasData) { + final position = snapshot.data as Duration; + return Text( + '${position.inMinutes}:${position.inSeconds % 60}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(width: 20), + Expanded( + child: _VideoPlayerProgress(controller: controller), + ), + const SizedBox(width: 20), + // 总时长 + StreamBuilder( + stream: controller.player.stream.duration, + builder: (context, snapshot) { + if (snapshot.hasData) { + final duration = snapshot.data as Duration; + return Text( + '${duration.inMinutes}:${duration.inSeconds % 60}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: // 音量 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _VideoPlayerVolume( + value: controller.player.state.volume, + onVolumeChanged: (value) { + controller.player.setVolume(value); + }, + ), + // 画质 + Obx(() { + if (controller.currentQality.value.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(left: 10), + child: _VideoPlayerQuality(controller: controller), + ); + }), + ], + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 上一集 + Obx( + () => IconButton( + onPressed: controller.index.value > 0 + ? () { + controller.index.value--; + } + : null, + icon: const Icon( + FluentIcons.previous, + ), + ), + ), + const SizedBox(width: 20), + StreamBuilder( + stream: controller.player.stream.playing, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!) { + return IconButton( + onPressed: controller.player.pause, + icon: const Icon( + FluentIcons.pause, + size: 30, + ), + ); + } + return IconButton( + onPressed: controller.player.play, + icon: const Icon( + FluentIcons.play, + size: 30, + ), + ); + }, + ), + const SizedBox(width: 20), + // 下一集 + Obx( + () => IconButton( + onPressed: controller.playList.length - 1 > + controller.index.value + ? () { + controller.index.value++; + } + : null, + icon: const Icon( + FluentIcons.next, + ), + ), + ), + ], + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // playback speed + Padding( + padding: const EdgeInsets.only(right: 10), + child: _VideoPlayerSpeed(controller: controller), + ), + // torrent files + Obx(() { + if (controller.torrentMediaFileList.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(right: 10), + child: _VideoPlayerTorrentFiles( + controller: controller, + ), + ); + }), + // track + _VideoPlayerTrack(controller: controller), + const SizedBox(width: 10), + + // 剧集 + _VideoPlayerEpisode(controller: controller), + const SizedBox(width: 10), + // 全屏 + Obx( + () => IconButton( + onPressed: () { + controller.toggleFullscreen(); + }, + icon: Icon( + controller.isFullScreen.value + ? FluentIcons.back_to_window + : FluentIcons.full_screen, + ), + ), + ), + const SizedBox(width: 10), + // 设置 + IconButton( + onPressed: () { + final showPlayList = controller.showPlayList.value; + controller.showPlayList.value = !showPlayList; + }, + icon: const Icon( + FluentIcons.settings, + ), + ), + ], + ), + ), + ], + ) + ], + ), + ), + ); + } +} + +class _VideoPlayerVolume extends StatefulWidget { + const _VideoPlayerVolume({ + required this.value, + required this.onVolumeChanged, + }); + final double value; + final Function(double value) onVolumeChanged; + + @override + State<_VideoPlayerVolume> createState() => _VideoPlayerVolumeState(); +} + +class _VideoPlayerVolumeState extends State<_VideoPlayerVolume> { + final _controller = FlyoutController(); + final _volume = 0.0.obs; + @override + void initState() { + super.initState(); + _volume.value = widget.value; + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: _controller, + child: IconButton( + icon: Obx( + () => Icon( + _volume.value == 0 + ? FluentIcons.volume0 + : _volume.value < 50 + ? FluentIcons.volume1 + : _volume.value < 100 + ? FluentIcons.volume2 + : FluentIcons.volume3, + ), + ), + onPressed: () { + _controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + useAcrylic: true, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Obx( + () => Icon( + _volume.value == 0 + ? FluentIcons.volume0 + : _volume.value < 50 + ? FluentIcons.volume1 + : _volume.value < 100 + ? FluentIcons.volume2 + : FluentIcons.volume3, + ), + ), + const SizedBox(width: 10), + Obx( + () => SizedBox( + height: 30, + child: Slider( + value: _volume.value, + max: 100, + onChanged: (value) { + _volume.value = value; + widget.onVolumeChanged(value); + }, + ), + ), + ), + const SizedBox(width: 10), + Obx( + () => Text( + _volume.value.toStringAsFixed(1), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _VideoPlayerEpisode extends StatefulWidget { + const _VideoPlayerEpisode({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_VideoPlayerEpisode> createState() => _VideoPlayerEpisodeState(); +} + +class _VideoPlayerEpisodeState extends State<_VideoPlayerEpisode> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutTarget( + controller: controller, + child: IconButton( + icon: const Icon(FluentIcons.playlist_music), + onPressed: () { + controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + padding: const EdgeInsets.all(0), + useAcrylic: true, + child: Container( + width: 300, + constraints: const BoxConstraints( + maxHeight: 500, + ), + child: PlayList( + title: widget.controller.title, + list: widget.controller.playList + .map((e) => e.name) + .toList(), + selectIndex: widget.controller.index.value, + onChange: (value) { + widget.controller.index.value = value; + router.pop(); + }, + ), + ), + ), + ); + }, + ); + }, + ), + ), + ); + } +} + +class _VideoPlayerQuality extends StatefulWidget { + const _VideoPlayerQuality({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_VideoPlayerQuality> createState() => _VideoPlayerQualityState(); +} + +class _VideoPlayerQualityState extends State<_VideoPlayerQuality> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: controller, + child: Button( + child: Text(widget.controller.currentQality.value), + onPressed: () { + if (widget.controller.qualityMap.isEmpty) { + widget.controller.sendMessage( + Message(const Text("No quality available")), + ); + return; + } + controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + useAcrylic: true, + child: Container( + width: 200, + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: ListView( + children: [ + for (final quality + in widget.controller.qualityMap.entries) + ListTile( + title: Text(quality.key), + onPressed: () { + widget.controller.switchQuality( + quality.value, + ); + router.pop(); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _VideoPlayerTrack extends StatefulWidget { + const _VideoPlayerTrack({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_VideoPlayerTrack> createState() => _VideoPlayerTrackState(); +} + +class _VideoPlayerTrackState extends State<_VideoPlayerTrack> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: controller, + child: IconButton( + icon: const Icon(FluentIcons.locale_language), + onPressed: () { + controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + useAcrylic: true, + padding: const EdgeInsets.all(0), + child: Container( + width: 220, + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + "Subtitles", + style: TextStyle( + fontSize: 13, + color: Colors.white.withAlpha(200), + ), + ), + ), + const SizedBox(height: 5), + ListTile.selectable( + selected: SubtitleTrack.no() == + widget.controller.player.state.track.subtitle, + title: const Text('Off'), + onPressed: () { + widget.controller.player.setSubtitleTrack( + SubtitleTrack.no(), + ); + router.pop(); + }, + ), + // 来自扩展的字幕 + for (final subtitle in widget.controller.subtitles) + ListTile.selectable( + selected: subtitle == + widget.controller.player.state.track.subtitle, + title: Text(subtitle.title ?? ''), + subtitle: Text(subtitle.language ?? ''), + onPressed: () { + widget.controller.player.setSubtitleTrack( + subtitle, + ); + router.pop(); + }, + ), + // 来自视频的字幕 + for (final subtitle + in widget.controller.player.state.tracks.subtitle) + if (subtitle != SubtitleTrack.no() && + (subtitle.language != null || + subtitle.title != null)) + ListTile.selectable( + selected: subtitle == + widget.controller.player.state.track.subtitle, + title: Text(subtitle.title ?? ''), + subtitle: Text(subtitle.language ?? ''), + onPressed: () { + widget.controller.player.setSubtitleTrack( + subtitle, + ); + router.pop(); + }, + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + "Audio Tracks", + style: TextStyle( + fontSize: 13, + color: Colors.white.withAlpha(200), + ), + ), + ), + const SizedBox(height: 5), + // 来自视频的音轨 + for (final audio + in widget.controller.player.state.tracks.audio) + if (audio.language != null || audio.title != null) + ListTile.selectable( + selected: audio == + widget.controller.player.state.track.audio, + title: Text(audio.title ?? ''), + subtitle: Text(audio.language ?? ''), + onPressed: () { + widget.controller.player.setAudioTrack( + audio, + ); + router.pop(); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _VideoPlayerTorrentFiles extends StatefulWidget { + const _VideoPlayerTorrentFiles({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_VideoPlayerTorrentFiles> createState() => + _VideoPlayerTorrentFilesState(); +} + +class _VideoPlayerTorrentFilesState extends State<_VideoPlayerTorrentFiles> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: controller, + child: IconButton( + icon: const Icon(FluentIcons.folder_open), + onPressed: () { + controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + useAcrylic: true, + padding: const EdgeInsets.all(0), + child: Container( + width: 300, + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + for (final file + in widget.controller.torrentMediaFileList) + ListTile.selectable( + title: Text( + file, + style: const TextStyle(fontSize: 13), + ), + selected: + widget.controller.currentTorrentFile.value == + file, + onPressed: () { + widget.controller.playTorrentFile(file); + router.pop(); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _VideoPlayerSpeed extends StatefulWidget { + const _VideoPlayerSpeed({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_VideoPlayerSpeed> createState() => _VideoPlayerSpeedState(); +} + +class _VideoPlayerSpeedState extends State<_VideoPlayerSpeed> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: controller, + child: Button( + child: Obx(() => Text('x${widget.controller.currentSpeed.value}')), + onPressed: () { + controller.showFlyout( + barrierDismissible: false, + dismissOnPointerMoveAway: true, + builder: (context) { + return FluentTheme( + data: FluentThemeData.dark(), + child: FlyoutContent( + useAcrylic: true, + padding: const EdgeInsets.all(0), + child: Container( + width: 200, + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + for (final speed in widget.controller.speedList) + ListTile.selectable( + title: Text( + speed.toStringAsFixed(2), + style: const TextStyle(fontSize: 13), + ), + selected: + widget.controller.currentSpeed.value == speed, + onPressed: () { + widget.controller.player.setRate(speed); + widget.controller.currentSpeed.value = speed; + router.pop(); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _VideoPlayerProgress extends StatefulWidget { + const _VideoPlayerProgress({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + State<_VideoPlayerProgress> createState() => _VideoPlayerProgressState(); +} + +class _VideoPlayerProgressState extends State<_VideoPlayerProgress> { + Duration position = const Duration(); + Duration duration = const Duration(); + bool _isDrag = false; + + @override + void initState() { + super.initState(); + widget.controller.player.stream.position.listen((event) { + if (!_isDrag) { + position = event; + } + }); + widget.controller.player.stream.duration.listen((event) { + duration = event; + }); + } + + @override + Widget build(BuildContext context) { + return Slider( + value: (position.inSeconds).toDouble(), + max: (duration.inSeconds).toDouble(), + label: '${position.inMinutes}:${position.inSeconds % 60}', + onChanged: (value) { + _isDrag = true; + setState(() { + position = Duration(seconds: value.toInt()); + }); + }, + onChangeEnd: (value) { + _isDrag = false; + widget.controller.player.seek( + Duration( + seconds: value.toInt(), + ), + ); + }, + ); + } +} diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart new file mode 100644 index 00000000..fe094afc --- /dev/null +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:get/get.dart'; +import 'package:miru_app/controllers/watch/video_controller.dart'; +import 'package:miru_app/views/widgets/platform_widget.dart'; +import 'package:miru_app/views/widgets/watch/playlist.dart'; + +class VideoPlayerSidebar extends StatefulWidget { + const VideoPlayerSidebar({ + super.key, + required this.controller, + }); + final VideoPlayerController controller; + + @override + State createState() => _VideoPlayerSidebarState(); +} + +class _VideoPlayerSidebarState extends State { + late final _c = widget.controller; + + late final Map _tabs = { + "Episodes": PlayList( + title: _c.title, + list: _c.playList.map((e) => e.name).toList(), + selectIndex: _c.index.value, + onChange: (value) { + _c.index.value = value; + _c.showPlayList.value = false; + }, + ), + "Subtitles": const Text('Subtitles'), + }; + + String _selectedTab = "Episodes"; + + Widget _buildAndroid(BuildContext context) { + return _tabs[_selectedTab]!; + } + + Widget _buildDesktop(BuildContext context) { + return fluent.FluentTheme( + data: fluent.FluentThemeData( + brightness: Brightness.dark, + ), + child: Container( + color: fluent.FluentThemeData.dark().micaBackgroundColor, + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Text( + "Settings", + style: fluent.FluentThemeData.dark().typography.bodyLarge, + ), + const SizedBox(height: 20), + _tabs["Settings"]! + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + // if (c.torrentMediaFileList.isNotEmpty) { + // tabs.add( + // const Tab( + // text: 'Torrent File', + // ), + // ); + // } + + // if (c.qualityUrls.isNotEmpty) { + // tabs.add( + // const Tab( + // text: 'Quality', + // ), + // ); + // } + + _tabs["Settings"] = const Text('Settings'); + + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + }); + } +} diff --git a/lib/views/widgets/watch/playlist.dart b/lib/views/widgets/watch/playlist.dart index 7900921d..1557a366 100644 --- a/lib/views/widgets/watch/playlist.dart +++ b/lib/views/widgets/watch/playlist.dart @@ -38,23 +38,20 @@ class PlayList extends fluent.StatelessWidget { } Widget _buildDesktop(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - color: fluent.FluentTheme.of(context).micaBackgroundColor, - child: ScrollablePositionedList.builder( - itemCount: list.length, - initialScrollIndex: selectIndex, - itemBuilder: (context, index) { - final contact = list[index]; - return fluent.ListTile.selectable( - title: Text(contact), - selected: list[selectIndex] == contact, - onSelectionChange: (value) { - onChange(index); - }, - ); - }, - ), + return ScrollablePositionedList.builder( + itemCount: list.length, + initialScrollIndex: selectIndex, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final contact = list[index]; + return fluent.ListTile.selectable( + title: Text(contact), + onPressed: () { + onChange(index); + }, + selected: list[selectIndex] == contact, + ); + }, ); } From 51b264758e09e81f4eec0893250e6f324e0209ce Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sun, 28 Jan 2024 09:34:44 +0800 Subject: [PATCH 02/19] Add subtitle configuration and error handling --- lib/controllers/watch/video_controller.dart | 167 ++++---- .../watch/video/video_player_content.dart | 4 + .../watch/video/video_player_controls.dart | 231 +++++++++-- .../watch/video/video_player_sidebar.dart | 361 ++++++++++++++++-- 4 files changed, 641 insertions(+), 122 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 97491135..a0b739bd 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -52,8 +52,10 @@ class VideoPlayerController extends GetxController { required this.anilistID, }); + // 播放器 final player = Player(); late final videoController = VideoController(player); + final showPlayList = false.obs; final isOpenSidebar = false.obs; final isFullScreen = false.obs; @@ -139,7 +141,17 @@ class VideoPlayerController extends GetxController { String _torrenHash = ""; // 调用 watch 方法获取到的数据 - final Rx watchData = Rx(null); + ExtensionBangumiWatch? watchData; + final error = "".obs; + final isGettingWatchData = true.obs; + + // 字幕配置 + final subtitleFontSize = 24.0.obs; + final subtitleBackgroundColor = const Color(0xaa000000).obs; + final subtitleTextColor = Colors.white.obs; + final subtitleTextHeight = 1.4.obs; + final subtitleFontWeight = FontWeight.normal.obs; + final subtitleTextAlign = TextAlign.center.obs; @override void onInit() async { @@ -157,7 +169,6 @@ class VideoPlayerController extends GetxController { await (player.platform as dynamic) .setProperty('demuxer-max-bytes', '30MiB'); } - play(); // 切换剧集 ever(index, (callback) { @@ -225,73 +236,51 @@ class VideoPlayerController extends GetxController { sendMessage(Message(Text(event))); }); + play(); super.onInit(); } + // 播放 play() async { - player.stop(); // 如果已经 delete 当前 controller if (!Get.isRegistered(tag: title)) { return; } + player.stop(); + isGettingWatchData.value = true; + try { + await getWatchData(); + } catch (e) { + logger.severe(e); + error.value = e.toString(); + return; + } try { - watchData.value = null; - subtitles.clear(); - final playUrl = playList[index.value].url; - watchData.value = await runtime.watch(playUrl) as ExtensionBangumiWatch; - - if (watchData.value!.type == ExtensionWatchBangumiType.torrent) { - if (Get.find().btServerisRunning.value == false) { - await BTServerUtils.startServer(); - } - sendMessage( - Message( - Text('video.torrent-downloading'.i18n), - ), - ); - // 下载 torrent - final torrentFile = path.join( - MiruDirectory.getCacheDirectory, - 'temp.torrent', - ); - await dio.download(watchData.value!.url, torrentFile); - final file = File(torrentFile); - _torrenHash = await BTServerApi.addTorrent(file.readAsBytesSync()); - - final files = await BTServerApi.getFileList(_torrenHash); - - torrentMediaFileList.clear(); - - for (final file in files) { - if (_isSubtitle(file)) { - subtitles.add( - SubtitleTrack.uri( - '${BTServerApi.baseApi}/torrent/$_torrenHash/$file', - title: path.basename(file), - ), - ); - } else { - torrentMediaFileList.add(file); - } + if (watchData!.type == ExtensionWatchBangumiType.torrent) { + try { + await getTorrentMediaFile(); + } catch (e) { + logger.severe(e); + error.value = e.toString(); + return; } - playTorrentFile(torrentMediaFileList.first); } else { getQuality(); await player.open( - Media(watchData.value!.url, httpHeaders: watchData.value!.headers), + Media(watchData!.url, httpHeaders: watchData!.headers), ); - if (watchData.value!.audioTrack != null) { + if (watchData!.audioTrack != null) { await player.setAudioTrack( - AudioTrack.uri(watchData.value!.audioTrack!), + AudioTrack.uri(watchData!.audioTrack!), ); } } - + isGettingWatchData.value = false; // 添加来自扩展的字幕 subtitles.addAll( - (watchData.value!.subtitles ?? []).map( + (watchData!.subtitles ?? []).map( (e) => SubtitleTrack.uri( e.url, language: e.language, @@ -300,26 +289,25 @@ class VideoPlayerController extends GetxController { ), ); player.setSubtitleTrack(SubtitleTrack.no()); - } catch (e) { + } on StartServerException catch (_) { // 如果是 启动 bt server 失败 - if (e is StartServerException) { - if (Platform.isAndroid) { - await showDialog( - context: currentContext, - builder: (context) => const BTDialog(), - ); - } else { - await fluent.showDialog( - context: currentContext, - builder: (context) => const BTDialog(), - ); - } - - // 延时 3 秒再重试 - await Future.delayed(const Duration(seconds: 3)); - play(); - return; + if (Platform.isAndroid) { + await showDialog( + context: currentContext, + builder: (context) => const BTDialog(), + ); + } else { + await fluent.showDialog( + context: currentContext, + builder: (context) => const BTDialog(), + ); } + + // 延时 3 秒再重试 + await Future.delayed(const Duration(seconds: 3)); + play(); + return; + } catch (e) { sendMessage( Message( Text(e.toString()), @@ -330,9 +318,52 @@ class VideoPlayerController extends GetxController { } } + getWatchData() async { + watchData = null; + subtitles.clear(); + final playUrl = playList[index.value].url; + watchData = await runtime.watch(playUrl) as ExtensionBangumiWatch; + } + + getTorrentMediaFile() async { + if (Get.find().btServerisRunning.value == false) { + await BTServerUtils.startServer(); + } + sendMessage( + Message( + Text('video.torrent-downloading'.i18n), + ), + ); + // 下载 torrent + final torrentFile = path.join( + MiruDirectory.getCacheDirectory, + 'temp.torrent', + ); + await dio.download(watchData!.url, torrentFile); + + final file = File(torrentFile); + _torrenHash = await BTServerApi.addTorrent(file.readAsBytesSync()); + final files = await BTServerApi.getFileList(_torrenHash); + + torrentMediaFileList.clear(); + + for (final file in files) { + if (_isSubtitle(file)) { + subtitles.add( + SubtitleTrack.uri( + '${BTServerApi.baseApi}/torrent/$_torrenHash/$file', + title: path.basename(file), + ), + ); + } else { + torrentMediaFileList.add(file); + } + } + } + getQuality() async { - final url = watchData.value!.url; - final headers = watchData.value!.headers; + final url = watchData!.url; + final headers = watchData!.headers; try { final response = await dio.get( url, @@ -368,7 +399,7 @@ class VideoPlayerController extends GetxController { switchQuality(String qualityUrl) async { final currentSecond = player.state.position.inSeconds; - final headers = watchData.value!.headers; + final headers = watchData!.headers; await player.open( Media(qualityUrl, httpHeaders: headers), ); diff --git a/lib/views/pages/watch/video/video_player_content.dart b/lib/views/pages/watch/video/video_player_content.dart index e3acee8b..4702a588 100644 --- a/lib/views/pages/watch/video/video_player_content.dart +++ b/lib/views/pages/watch/video/video_player_content.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/utils/log.dart'; import 'package:miru_app/utils/router.dart'; import 'package:miru_app/views/pages/watch/video/video_player_controls.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; @@ -75,6 +76,9 @@ class _VideoPlayerContenState extends State { Widget _buildDesktop(BuildContext context) { return Video( controller: _c.videoController, + subtitleViewConfiguration: const SubtitleViewConfiguration( + visible: false, + ), controls: (state) { return VideoPlayerControls( tag: widget.tag, diff --git a/lib/views/pages/watch/video/video_player_controls.dart b/lib/views/pages/watch/video/video_player_controls.dart index 0b551b15..87e669ef 100644 --- a/lib/views/pages/watch/video/video_player_controls.dart +++ b/lib/views/pages/watch/video/video_player_controls.dart @@ -1,11 +1,16 @@ import 'dart:async'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; 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/controllers/watch/video_controller.dart'; import 'package:miru_app/router/router.dart'; +import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; import 'package:window_manager/window_manager.dart'; @@ -25,6 +30,7 @@ class _VideoPlayerControlsState extends State { final FocusNode _focusNode = FocusNode(); Timer? _timer; bool showControls = true; + final _subtitleViewKey = GlobalKey(); @override void initState() { @@ -73,46 +79,183 @@ class _VideoPlayerControlsState extends State { c.keyboardShortcuts[value.logicalKey]?.call(); } }, - child: Column( + child: Stack( children: [ - Opacity( - opacity: showControls ? 1 : 0, - child: _VideoPlayerControlsHeader( - title: c.title, - episode: c.playList[c.index.value].name, - onClose: () { - if (c.isFullScreen.value) { - WindowManager.instance.setFullScreen(false); - } - router.pop(); + const Positioned.fill( + child: SizedBox.expand(), + ), + // subtitle + Positioned.fill( + child: // subtitle + Obx( + () { + final textStyle = TextStyle( + height: c.subtitleTextHeight.value, + fontSize: c.subtitleFontSize.value, + letterSpacing: 0.0, + wordSpacing: 0.0, + color: c.subtitleTextColor.value, + fontWeight: c.subtitleFontWeight.value, + backgroundColor: c.subtitleBackgroundColor.value, + ); + _subtitleViewKey.currentState?.textAlign = + c.subtitleTextAlign.value; + _subtitleViewKey.currentState?.style = textStyle; + _subtitleViewKey.currentState?.padding = + EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + showControls ? 100.0 : 16.0, + ); + return SubtitleView( + controller: c.videoController, + configuration: SubtitleViewConfiguration( + style: textStyle, + textAlign: c.subtitleTextAlign.value, + ), + key: _subtitleViewKey, + ); }, ), ), - Expanded( - child: Center( - child: Obx(() { - if (c.watchData.value != null) { - return StreamBuilder( - stream: c.player.stream.buffering, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! || - c.player.state.buffering) { - return const ProgressRing(); + Positioned.fill( + child: Column( + children: [ + // header + Opacity( + opacity: showControls ? 1 : 0, + child: _VideoPlayerControlsHeader( + title: c.title, + episode: c.playList[c.index.value].name, + onClose: () { + if (c.isFullScreen.value) { + WindowManager.instance.setFullScreen(false); } - return const SizedBox.shrink(); + router.pop(); }, - ); - } - return Text( - 'Getting play link from ${c.runtime.extension.name}...', - ); - }), + ), + ), + // center + Expanded( + child: Center( + child: Obx(() { + if (c.error.value.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Getting streamlink error", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Button( + child: const Text('Error message'), + onPressed: () { + showDialog( + context: context, + builder: (context) => ContentDialog( + constraints: const BoxConstraints( + maxWidth: 500, + ), + title: const Text('Error message'), + content: + SelectableText(c.error.value), + actions: [ + Button( + child: + Text('common.close'.i18n), + onPressed: () { + router.pop(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + Button( + child: Text('Retry'.i18n), + onPressed: () { + c.error.value = ''; + c.play(); + }, + ), + ], + ) + ], + ); + } + if (!c.isGettingWatchData.value) { + return StreamBuilder( + stream: c.player.stream.buffering, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! || + c.player.state.buffering) { + return const ProgressRing(); + } + return const SizedBox.shrink(); + }, + ); + } + return Card( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (c.runtime.extension.icon != null) + Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(right: 10), + child: CacheNetWorkImagePic( + c.runtime.extension.icon!, + width: 30, + height: 30, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.runtime.extension.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Getting streamlink...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) + ], + ), + ); + }), + ), + ), + // footer + Opacity( + opacity: showControls ? 1 : 0, + child: _VideoPlayerFooter(controller: c), + ), + ], ), ), - Opacity( - opacity: showControls ? 1 : 0, - child: _VideoPlayerFooter(controller: c), - ), ], ), ), @@ -639,6 +782,24 @@ class _VideoPlayerTrack extends StatefulWidget { class _VideoPlayerTrackState extends State<_VideoPlayerTrack> { final controller = FlyoutController(); + _addSubtitle() async { + final file = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['srt', 'vtt'], + allowMultiple: false, + ); + if (file == null) { + return; + } + final data = File(file.files.first.path!).readAsStringSync(); + widget.controller.subtitles.add( + SubtitleTrack.data( + data, + title: file.files.first.name, + ), + ); + } + @override dispose() { super.dispose(); @@ -691,6 +852,12 @@ class _VideoPlayerTrackState extends State<_VideoPlayerTrack> { router.pop(); }, ), + ListTile.selectable( + title: const Text('Add subtitle file'), + onPressed: () { + _addSubtitle(); + }, + ), // 来自扩展的字幕 for (final subtitle in widget.controller.subtitles) ListTile.selectable( diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index fe094afc..14e48bc4 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -29,7 +29,9 @@ class _VideoPlayerSidebarState extends State { _c.showPlayList.value = false; }, ), - "Subtitles": const Text('Subtitles'), + "Settings": _SideBarSettings( + controller: _c, + ), }; String _selectedTab = "Episodes"; @@ -62,29 +64,344 @@ class _VideoPlayerSidebarState extends State { @override Widget build(BuildContext context) { - return Obx(() { - // if (c.torrentMediaFileList.isNotEmpty) { - // tabs.add( - // const Tab( - // text: 'Torrent File', - // ), - // ); - // } + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +} - // if (c.qualityUrls.isNotEmpty) { - // tabs.add( - // const Tab( - // text: 'Quality', - // ), - // ); - // } +class _SideBarSettings extends StatefulWidget { + const _SideBarSettings({ + required this.controller, + }); + final VideoPlayerController controller; - _tabs["Settings"] = const Text('Settings'); + @override + State<_SideBarSettings> createState() => _SideBarSettingsState(); +} - return PlatformBuildWidget( - androidBuilder: _buildAndroid, - desktopBuilder: _buildDesktop, - ); - }); +class _SideBarSettingsState extends State<_SideBarSettings> { + late final _c = widget.controller; + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Subtitle'), + const SizedBox(height: 10), + Row( + children: [ + const Text('Font size'), + const SizedBox(width: 10), + Expanded( + child: Obx( + () => fluent.Slider( + value: _c.subtitleFontSize.value, + onChanged: (value) { + _c.subtitleFontSize.value = value; + }, + min: 20, + max: 80, + ), + ), + ), + const SizedBox(width: 10), + Obx( + () => Text( + _c.subtitleFontSize.value.toStringAsFixed(0), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Text('Font color'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: _c.subtitleTextColor.value == Colors.white, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextColor.value = Colors.white; + Navigator.of(context).pop(Colors.white); + }, + child: Container( + height: 32, + width: 32, + color: Colors.white, + ), + ), + ...fluent.Colors.accentColors.map((color) { + return fluent.Button( + autofocus: _c.subtitleTextColor.value == color, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextColor.value = color; + Navigator.of(context).pop(color); + }, + child: Container( + height: 32, + width: 32, + color: color, + ), + ); + }), + ], + ), + ), + ), + child: Obx( + () => Container( + decoration: BoxDecoration( + color: _c.subtitleTextColor.value, + borderRadius: const BorderRadiusDirectional.horizontal( + start: Radius.circular(4.0), + ), + ), + height: 32, + width: 36, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Text('Background color'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: _c.subtitleBackgroundColor.value == + Colors.transparent, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleBackgroundColor.value = Colors.transparent; + Navigator.of(context).pop(Colors.transparent); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + ), + ), + ...fluent.Colors.accentColors.map((color) { + return fluent.Button( + autofocus: _c.subtitleBackgroundColor.value == color, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleBackgroundColor.value = color; + Navigator.of(context).pop(color); + }, + child: Container( + height: 32, + width: 32, + color: color, + ), + ); + }), + ], + ), + ), + ), + child: Obx( + () => Container( + decoration: BoxDecoration( + color: _c.subtitleBackgroundColor.value, + borderRadius: const BorderRadiusDirectional.horizontal( + start: Radius.circular(4.0), + ), + ), + height: 32, + width: 36, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Text('Background opacity'), + const SizedBox(width: 10), + Expanded( + child: Obx( + () => fluent.Slider( + value: _c.subtitleBackgroundColor.value.opacity, + onChanged: (value) { + _c.subtitleBackgroundColor.value = + _c.subtitleBackgroundColor.value.withOpacity(value); + }, + min: 0, + max: 1, + ), + ), + ), + const SizedBox(width: 10), + Obx( + () => Text( + _c.subtitleBackgroundColor.value.opacity.toStringAsFixed(2), + ), + ), + ], + ), + const SizedBox(height: 10), + // textAlign + Row( + children: [ + const Text('Text align'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: + _c.subtitleTextAlign.value == TextAlign.justify, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.justify; + Navigator.of(context).pop(TextAlign.justify); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_justify, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: _c.subtitleTextAlign.value == TextAlign.left, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.left; + Navigator.of(context).pop(TextAlign.left); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_left, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: + _c.subtitleTextAlign.value == TextAlign.right, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.right; + Navigator.of(context).pop(TextAlign.right); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_right, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: + _c.subtitleTextAlign.value == TextAlign.center, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.center; + Navigator.of(context).pop(TextAlign.center); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_center, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + child: Obx( + () => SizedBox( + height: 32, + width: 36, + child: Icon( + _c.subtitleTextAlign.value == TextAlign.justify + ? Icons.format_align_justify + : _c.subtitleTextAlign.value == TextAlign.left + ? Icons.format_align_left + : _c.subtitleTextAlign.value == TextAlign.right + ? Icons.format_align_right + : Icons.format_align_center, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ); } } From 48b06e7ddd407ad1d733ba4870a629725ac93960 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sun, 28 Jan 2024 15:49:14 +0800 Subject: [PATCH 03/19] Update sidebar variable names and subtitle font size --- lib/controllers/watch/video_controller.dart | 24 +- lib/views/pages/watch/video/video_player.dart | 4 +- .../watch/video/video_player_content.dart | 278 +------ ...art => video_player_desktop_controls.dart} | 663 ++++++++-------- .../video/video_player_mobile_controls.dart | 397 ++++++++++ .../watch/video/video_player_sidebar.dart | 709 ++++++++++-------- 6 files changed, 1185 insertions(+), 890 deletions(-) rename lib/views/pages/watch/video/{video_player_controls.dart => video_player_desktop_controls.dart} (65%) create mode 100644 lib/views/pages/watch/video/video_player_mobile_controls.dart diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index a0b739bd..5e8e9441 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -56,7 +56,7 @@ class VideoPlayerController extends GetxController { final player = Player(); late final videoController = VideoController(player); - final showPlayList = false.obs; + final showSidebar = false.obs; final isOpenSidebar = false.obs; final isFullScreen = false.obs; late final index = playIndex.obs; @@ -146,12 +146,14 @@ class VideoPlayerController extends GetxController { final isGettingWatchData = true.obs; // 字幕配置 - final subtitleFontSize = 24.0.obs; - final subtitleBackgroundColor = const Color(0xaa000000).obs; - final subtitleTextColor = Colors.white.obs; - final subtitleTextHeight = 1.4.obs; + final subtitleFontSize = 34.0.obs; final subtitleFontWeight = FontWeight.normal.obs; final subtitleTextAlign = TextAlign.center.obs; + final subtitleFontColor = Colors.white.obs; + final subtitleBackgroundColor = const Color(0xaa000000).obs; + + // 播放方式 + final playMode = PlaylistMode.none.obs; @override void onInit() async { @@ -181,17 +183,23 @@ class VideoPlayerController extends GetxController { }); // 显示剧集列表 - ever(showPlayList, (callback) { - if (!showPlayList.value) { + ever(showSidebar, (callback) { + if (!showSidebar.value) { isOpenSidebar.value = false; } }); // 自动切换下一集 player.stream.completed.listen((event) { - if (!event) { + if (!event || playMode.value == PlaylistMode.single) { return; } + if (playMode.value == PlaylistMode.loop) { + player.seek(Duration.zero); + player.play(); + return; + } + if (index.value == playList.length - 1) { sendMessage(Message(Text('video.play-complete'.i18n))); return; diff --git a/lib/views/pages/watch/video/video_player.dart b/lib/views/pages/watch/video/video_player.dart index 20c40e18..15636dfb 100644 --- a/lib/views/pages/watch/video/video_player.dart +++ b/lib/views/pages/watch/video/video_player.dart @@ -70,9 +70,9 @@ class _VideoPlayerState extends State { children: [ AnimatedContainer( onEnd: () { - _c.isOpenSidebar.value = _c.showPlayList.value; + _c.isOpenSidebar.value = _c.showSidebar.value; }, - width: _c.showPlayList.value + width: _c.showSidebar.value ? MediaQuery.of(context).size.width - 300 : maxWidth, duration: const Duration(milliseconds: 120), diff --git a/lib/views/pages/watch/video/video_player_content.dart b/lib/views/pages/watch/video/video_player_content.dart index 4702a588..7f4e2b45 100644 --- a/lib/views/pages/watch/video/video_player_content.dart +++ b/lib/views/pages/watch/video/video_player_content.dart @@ -4,14 +4,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; -import 'package:miru_app/utils/i18n.dart'; -import 'package:miru_app/utils/log.dart'; -import 'package:miru_app/utils/router.dart'; -import 'package:miru_app/views/pages/watch/video/video_player_controls.dart'; -import 'package:miru_app/views/widgets/platform_widget.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_desktop_controls.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_mobile_controls.dart'; -class VideoPlayerConten extends StatefulWidget { +class VideoPlayerConten extends StatelessWidget { const VideoPlayerConten({ super.key, required this.tag, @@ -19,271 +15,23 @@ class VideoPlayerConten extends StatefulWidget { final String tag; @override - State createState() => _VideoPlayerContenState(); -} - -class _VideoPlayerContenState extends State { - late final _c = Get.find(tag: widget.tag); - - final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; - late final topButtonBar = Row( - children: [ - Expanded( - child: Obx( - () => DefaultTextStyle( - style: const TextStyle( - overflow: TextOverflow.ellipsis, - color: Colors.white, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _c.title, - style: const TextStyle( - fontSize: 18, - ), - ), - Text( - _c.playList[_c.index.value].name, - style: const TextStyle( - fontSize: 14, - ), - ) - ], - )), - ), - ), - Tooltip( - message: "video.tooltip.close".i18n, - child: MaterialDesktopCustomButton( - icon: const Icon( - Icons.keyboard_arrow_down, - color: Colors.white, - ), - onPressed: () async { - if (!Platform.isAndroid) { - await WindowManager.instance.setFullScreen(false); - } - await _c.onExit(); - RouterUtils.pop(); - }, - ), - ), - ], - ); - - Widget _buildDesktop(BuildContext context) { + Widget build(BuildContext context) { + final c = Get.find(tag: tag); return Video( - controller: _c.videoController, + controller: c.videoController, subtitleViewConfiguration: const SubtitleViewConfiguration( visible: false, ), controls: (state) { - return VideoPlayerControls( - tag: widget.tag, + if (Platform.isAndroid) { + return VideoPlayerMobileControls( + controller: c, + ); + } + return VideoPlayerDesktopControls( + controller: c, ); }, ); } - - Widget _buildAndroid(BuildContext context) { - return MaterialVideoControlsTheme( - normal: MaterialVideoControlsThemeData( - volumeGesture: true, - brightnessGesture: true, - topButtonBar: [Expanded(child: topButtonBar)], - topButtonBarMargin: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 20, - ), - 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(), - Theme( - data: Theme.of(context), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: PopupMenuButton( - child: Obx( - () => Text( - 'x${_c.currentSpeed.value}', - style: const TextStyle(color: Colors.white), - ), - ), - itemBuilder: (context) { - return [ - for (final speed in speeds) - PopupMenuItem( - child: Text('x$speed'), - onTap: () { - _c.currentSpeed.value = speed; - }, - ), - ]; - }, - ), - ), - Obx(() { - if (_c.torrentMediaFileList.isEmpty) { - return const SizedBox.shrink(); - } - return PopupMenuButton( - icon: const Icon( - Icons.file_open, - color: Colors.white, - ), - itemBuilder: (context) { - return [ - for (int i = 0; i < _c.torrentMediaFileList.length; i++) - PopupMenuItem( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 0), - child: Obx( - () => CheckboxListTile( - value: _c.currentTorrentFile.value == - _c.torrentMediaFileList[i], - onChanged: (_) { - _c.playTorrentFile( - _c.torrentMediaFileList[i], - ); - }, - title: Text(_c.torrentMediaFileList[i]), - ), - ), - ), - ]; - }, - ); - }), - PopupMenuButton( - icon: const Icon( - Icons.subtitles, - color: Colors.white, - ), - itemBuilder: (context) { - return [ - // 是否显示字幕 - // PopupMenuItem( - // padding: const EdgeInsets.symmetric( - // horizontal: 10, vertical: 0), - // child: Obx( - // () => CheckboxListTile( - // value: _c.selectedSubtitle.value == -1, - // onChanged: (value) { - // _c.selectedSubtitle.value = -1; - // }, - // title: Text('video.subtitle-none'.i18n), - // ), - // ), - // ), - // 选择文件 - // PopupMenuItem( - // padding: const EdgeInsets.symmetric( - // horizontal: 10, vertical: 0), - // child: Obx( - // () => CheckboxListTile( - // value: _c.selectedSubtitle.value == -2, - // onChanged: (value) { - // _c.selectedSubtitle.value = -2; - // }, - // title: Text("video.subtitle-file".i18n), - // ), - // ), - // ), - // for (int i = 0; i < _c.subtitles.length; i++) - // PopupMenuItem( - // padding: const EdgeInsets.symmetric( - // horizontal: 10, vertical: 0), - // child: Obx( - // () => CheckboxListTile( - // value: _c.selectedSubtitle.value == i, - // onChanged: (value) { - // _c.selectedSubtitle.value = i; - // }, - // title: Text(_c.subtitles[i].title), - // ), - // ), - // ), - ]; - }, - ), - // Obx(() { - // if (_c.currentQality.value.isEmpty) { - // return const SizedBox.shrink(); - // } - // return Padding( - // padding: const EdgeInsets.symmetric(horizontal: 8.0), - // child: PopupMenuButton( - // child: Obx( - // () => Text( - // _c.currentQality.value, - // style: const TextStyle(color: Colors.white), - // ), - // ), - // itemBuilder: (context) { - // return [ - // for (final qualit in _c.qualityUrls.entries) - // PopupMenuItem( - // child: Text(qualit.key), - // onTap: () { - // _c.switchQuality(_c.qualityUrls[qualit.key]!); - // }, - // ), - // ]; - // }, - // ), - // ); - // }), - ], - ), - ), - 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: Video( - controller: _c.videoController, - ), - ); - } - - @override - Widget build(BuildContext context) { - return PlatformBuildWidget( - androidBuilder: _buildAndroid, - desktopBuilder: _buildDesktop, - ); - } } diff --git a/lib/views/pages/watch/video/video_player_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart similarity index 65% rename from lib/views/pages/watch/video/video_player_controls.dart rename to lib/views/pages/watch/video/video_player_desktop_controls.dart index 87e669ef..50465284 100644 --- a/lib/views/pages/watch/video/video_player_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -14,58 +14,55 @@ import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; import 'package:window_manager/window_manager.dart'; -class VideoPlayerControls extends StatefulWidget { - const VideoPlayerControls({ +class VideoPlayerDesktopControls extends StatefulWidget { + const VideoPlayerDesktopControls({ super.key, - required this.tag, + required this.controller, }); - final String tag; + final VideoPlayerController controller; @override - State createState() => _VideoPlayerControlsState(); + State createState() => + _VideoPlayerDesktopControlsState(); } -class _VideoPlayerControlsState extends State { - late final c = Get.find(tag: widget.tag); +class _VideoPlayerDesktopControlsState + extends State { + late final _c = widget.controller; final FocusNode _focusNode = FocusNode(); Timer? _timer; - bool showControls = true; + bool _showControls = true; final _subtitleViewKey = GlobalKey(); - @override - void initState() { - super.initState(); + _updateTimer() { + _timer?.cancel(); + _timer = null; + setState(() { + _showControls = true; + }); _timer = Timer.periodic( const Duration(seconds: 3), (_) { if (mounted) { setState(() { - showControls = false; + _showControls = false; }); } }, ); } + @override + void initState() { + super.initState(); + _updateTimer(); + } + @override Widget build(BuildContext context) { return MouseRegion( onHover: (_) { - _timer?.cancel(); - _timer = null; - setState(() { - showControls = true; - }); - _timer = Timer.periodic( - const Duration(seconds: 3), - (_) { - if (mounted) { - setState(() { - showControls = false; - }); - } - }, - ); + _updateTimer(); }, child: FluentTheme( data: FluentThemeData( @@ -76,13 +73,121 @@ class _VideoPlayerControlsState extends State { autofocus: true, onKeyEvent: (value) { if (value is KeyDownEvent) { - c.keyboardShortcuts[value.logicalKey]?.call(); + _c.keyboardShortcuts[value.logicalKey]?.call(); } }, child: Stack( children: [ - const Positioned.fill( - child: SizedBox.expand(), + Positioned.fill( + child: SizedBox.expand( + child: Center( + child: Obx(() { + if (_c.error.value.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Getting streamlink error", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Button( + child: const Text('Error message'), + onPressed: () { + showDialog( + context: context, + builder: (context) => ContentDialog( + constraints: const BoxConstraints( + maxWidth: 500, + ), + title: const Text('Error message'), + content: SelectableText(_c.error.value), + actions: [ + Button( + child: Text('common.close'.i18n), + onPressed: () { + router.pop(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + Button( + child: Text('Retry'.i18n), + onPressed: () { + _c.error.value = ''; + _c.play(); + }, + ), + ], + ) + ], + ); + } + if (!_c.isGettingWatchData.value) { + return StreamBuilder( + stream: _c.player.stream.buffering, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! || + _c.player.state.buffering) { + return const ProgressRing(); + } + return const SizedBox.shrink(); + }, + ); + } + return Card( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_c.runtime.extension.icon != null) + Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(right: 10), + child: CacheNetWorkImagePic( + _c.runtime.extension.icon!, + width: 30, + height: 30, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _c.runtime.extension.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Getting streamlink...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) + ], + ), + ); + }), + ), + ), ), // subtitle Positioned.fill( @@ -90,29 +195,29 @@ class _VideoPlayerControlsState extends State { Obx( () { final textStyle = TextStyle( - height: c.subtitleTextHeight.value, - fontSize: c.subtitleFontSize.value, + height: 1.4, + fontSize: _c.subtitleFontSize.value, letterSpacing: 0.0, wordSpacing: 0.0, - color: c.subtitleTextColor.value, - fontWeight: c.subtitleFontWeight.value, - backgroundColor: c.subtitleBackgroundColor.value, + color: _c.subtitleFontColor.value, + fontWeight: _c.subtitleFontWeight.value, + backgroundColor: _c.subtitleBackgroundColor.value, ); _subtitleViewKey.currentState?.textAlign = - c.subtitleTextAlign.value; + _c.subtitleTextAlign.value; _subtitleViewKey.currentState?.style = textStyle; _subtitleViewKey.currentState?.padding = EdgeInsets.fromLTRB( 16.0, 0.0, 16.0, - showControls ? 100.0 : 16.0, + _showControls ? 100.0 : 16.0, ); return SubtitleView( - controller: c.videoController, + controller: _c.videoController, configuration: SubtitleViewConfiguration( style: textStyle, - textAlign: c.subtitleTextAlign.value, + textAlign: _c.subtitleTextAlign.value, ), key: _subtitleViewKey, ); @@ -124,12 +229,12 @@ class _VideoPlayerControlsState extends State { children: [ // header Opacity( - opacity: showControls ? 1 : 0, - child: _VideoPlayerControlsHeader( - title: c.title, - episode: c.playList[c.index.value].name, + opacity: _showControls ? 1 : 0, + child: _Header( + title: _c.title, + episode: _c.playList[_c.index.value].name, onClose: () { - if (c.isFullScreen.value) { + if (_c.isFullScreen.value) { WindowManager.instance.setFullScreen(false); } router.pop(); @@ -137,121 +242,11 @@ class _VideoPlayerControlsState extends State { ), ), // center - Expanded( - child: Center( - child: Obx(() { - if (c.error.value.isNotEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "Getting streamlink error", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Button( - child: const Text('Error message'), - onPressed: () { - showDialog( - context: context, - builder: (context) => ContentDialog( - constraints: const BoxConstraints( - maxWidth: 500, - ), - title: const Text('Error message'), - content: - SelectableText(c.error.value), - actions: [ - Button( - child: - Text('common.close'.i18n), - onPressed: () { - router.pop(); - }, - ), - ], - ), - ); - }, - ), - const SizedBox(width: 10), - Button( - child: Text('Retry'.i18n), - onPressed: () { - c.error.value = ''; - c.play(); - }, - ), - ], - ) - ], - ); - } - if (!c.isGettingWatchData.value) { - return StreamBuilder( - stream: c.player.stream.buffering, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! || - c.player.state.buffering) { - return const ProgressRing(); - } - return const SizedBox.shrink(); - }, - ); - } - return Card( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (c.runtime.extension.icon != null) - Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - ), - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.only(right: 10), - child: CacheNetWorkImagePic( - c.runtime.extension.icon!, - width: 30, - height: 30, - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - c.runtime.extension.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Text( - 'Getting streamlink...', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - ), - ), - ], - ) - ], - ), - ); - }), - ), - ), + const Spacer(), // footer Opacity( - opacity: showControls ? 1 : 0, - child: _VideoPlayerFooter(controller: c), + opacity: _showControls ? 1 : 0, + child: _Footer(controller: _c), ), ], ), @@ -264,8 +259,8 @@ class _VideoPlayerControlsState extends State { } } -class _VideoPlayerControlsHeader extends StatelessWidget { - const _VideoPlayerControlsHeader({ +class _Header extends StatelessWidget { + const _Header({ required this.title, required this.episode, required this.onClose, @@ -305,6 +300,15 @@ class _VideoPlayerControlsHeader extends StatelessWidget { ), ), ), + IconButton( + icon: const Icon( + FluentIcons.chrome_minimize, + ), + onPressed: () { + WindowManager.instance.minimize(); + }, + ), + const SizedBox(width: 10), IconButton( onPressed: onClose, icon: const Icon( @@ -318,8 +322,8 @@ class _VideoPlayerControlsHeader extends StatelessWidget { } } -class _VideoPlayerFooter extends StatelessWidget { - const _VideoPlayerFooter({ +class _Footer extends StatelessWidget { + const _Footer({ required this.controller, }); final VideoPlayerController controller; @@ -353,7 +357,7 @@ class _VideoPlayerFooter extends StatelessWidget { ), const SizedBox(width: 20), Expanded( - child: _VideoPlayerProgress(controller: controller), + child: _Progress(controller: controller), ), const SizedBox(width: 20), // 总时长 @@ -376,148 +380,155 @@ class _VideoPlayerFooter extends StatelessWidget { ], ), const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: // 音量 - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _VideoPlayerVolume( - value: controller.player.state.volume, - onVolumeChanged: (value) { - controller.player.setVolume(value); - }, + LayoutBuilder(builder: (context, constraints) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (constraints.maxWidth > 500) + Expanded( + flex: 1, + child: // 音量 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Volume( + value: controller.player.state.volume, + onVolumeChanged: (value) { + controller.player.setVolume(value); + }, + ), + // 画质 + Obx(() { + if (controller.currentQality.value.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(left: 10), + child: _Quality(controller: controller), + ); + }), + ], ), - // 画质 - Obx(() { - if (controller.currentQality.value.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(left: 10), - child: _VideoPlayerQuality(controller: controller), - ); - }), - ], - ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 上一集 - Obx( - () => IconButton( - onPressed: controller.index.value > 0 - ? () { - controller.index.value--; - } - : null, - icon: const Icon( - FluentIcons.previous, + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 上一集 + Obx( + () => IconButton( + onPressed: controller.index.value > 0 + ? () { + controller.index.value--; + } + : null, + icon: const Icon( + FluentIcons.previous, + ), ), ), - ), - const SizedBox(width: 20), - StreamBuilder( - stream: controller.player.stream.playing, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!) { + const SizedBox(width: 20), + StreamBuilder( + stream: controller.player.stream.playing, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!) { + return IconButton( + onPressed: controller.player.pause, + icon: const Icon( + FluentIcons.pause, + size: 30, + ), + ); + } return IconButton( - onPressed: controller.player.pause, + onPressed: controller.player.play, icon: const Icon( - FluentIcons.pause, + FluentIcons.play, size: 30, ), ); - } - return IconButton( - onPressed: controller.player.play, + }, + ), + const SizedBox(width: 20), + // 下一集 + Obx( + () => IconButton( + onPressed: controller.playList.length - 1 > + controller.index.value + ? () { + controller.index.value++; + } + : null, icon: const Icon( - FluentIcons.play, - size: 30, + FluentIcons.next, ), - ); - }, - ), - const SizedBox(width: 20), - // 下一集 - Obx( - () => IconButton( - onPressed: controller.playList.length - 1 > - controller.index.value - ? () { - controller.index.value++; - } - : null, - icon: const Icon( - FluentIcons.next, ), ), - ), - ], + ], + ), ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - // playback speed - Padding( - padding: const EdgeInsets.only(right: 10), - child: _VideoPlayerSpeed(controller: controller), - ), - // torrent files - Obx(() { - if (controller.torrentMediaFileList.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(right: 10), - child: _VideoPlayerTorrentFiles( - controller: controller, - ), - ); - }), - // track - _VideoPlayerTrack(controller: controller), - const SizedBox(width: 10), + if (constraints.maxWidth > 500) + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // playback speed + if (constraints.maxWidth > 700) + Padding( + padding: const EdgeInsets.only(right: 10), + child: _Speed(controller: controller), + ), + // torrent files + if (constraints.maxWidth > 700) + Obx(() { + if (controller.torrentMediaFileList.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(right: 10), + child: _TorrentFiles( + controller: controller, + ), + ); + }), + // track + _Track(controller: controller), + const SizedBox(width: 10), - // 剧集 - _VideoPlayerEpisode(controller: controller), - const SizedBox(width: 10), - // 全屏 - Obx( - () => IconButton( - onPressed: () { - controller.toggleFullscreen(); - }, - icon: Icon( - controller.isFullScreen.value - ? FluentIcons.back_to_window - : FluentIcons.full_screen, + // 剧集 + _Episode(controller: controller), + const SizedBox(width: 10), + // 全屏 + Obx( + () => IconButton( + onPressed: () { + controller.toggleFullscreen(); + }, + icon: Icon( + controller.isFullScreen.value + ? FluentIcons.back_to_window + : FluentIcons.full_screen, + ), + ), ), - ), - ), - const SizedBox(width: 10), - // 设置 - IconButton( - onPressed: () { - final showPlayList = controller.showPlayList.value; - controller.showPlayList.value = !showPlayList; - }, - icon: const Icon( - FluentIcons.settings, - ), + const SizedBox(width: 10), + // 设置 + IconButton( + onPressed: () { + final showPlayList = controller.showSidebar.value; + controller.showSidebar.value = !showPlayList; + }, + icon: const Icon( + FluentIcons.settings, + ), + ), + ], ), - ], - ), - ), - ], - ) + ), + ], + ); + }) ], ), ), @@ -525,8 +536,8 @@ class _VideoPlayerFooter extends StatelessWidget { } } -class _VideoPlayerVolume extends StatefulWidget { - const _VideoPlayerVolume({ +class _Volume extends StatefulWidget { + const _Volume({ required this.value, required this.onVolumeChanged, }); @@ -534,10 +545,10 @@ class _VideoPlayerVolume extends StatefulWidget { final Function(double value) onVolumeChanged; @override - State<_VideoPlayerVolume> createState() => _VideoPlayerVolumeState(); + State<_Volume> createState() => _VolumeState(); } -class _VideoPlayerVolumeState extends State<_VideoPlayerVolume> { +class _VolumeState extends State<_Volume> { final _controller = FlyoutController(); final _volume = 0.0.obs; @override @@ -630,18 +641,18 @@ class _VideoPlayerVolumeState extends State<_VideoPlayerVolume> { } } -class _VideoPlayerEpisode extends StatefulWidget { - const _VideoPlayerEpisode({ +class _Episode extends StatefulWidget { + const _Episode({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerEpisode> createState() => _VideoPlayerEpisodeState(); + State<_Episode> createState() => _EpisodeState(); } -class _VideoPlayerEpisodeState extends State<_VideoPlayerEpisode> { +class _EpisodeState extends State<_Episode> { final controller = FlyoutController(); @override @@ -696,18 +707,18 @@ class _VideoPlayerEpisodeState extends State<_VideoPlayerEpisode> { } } -class _VideoPlayerQuality extends StatefulWidget { - const _VideoPlayerQuality({ +class _Quality extends StatefulWidget { + const _Quality({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerQuality> createState() => _VideoPlayerQualityState(); + State<_Quality> createState() => _QualityState(); } -class _VideoPlayerQualityState extends State<_VideoPlayerQuality> { +class _QualityState extends State<_Quality> { final controller = FlyoutController(); @override @@ -768,18 +779,18 @@ class _VideoPlayerQualityState extends State<_VideoPlayerQuality> { } } -class _VideoPlayerTrack extends StatefulWidget { - const _VideoPlayerTrack({ +class _Track extends StatefulWidget { + const _Track({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerTrack> createState() => _VideoPlayerTrackState(); + State<_Track> createState() => _TrackState(); } -class _VideoPlayerTrackState extends State<_VideoPlayerTrack> { +class _TrackState extends State<_Track> { final controller = FlyoutController(); _addSubtitle() async { @@ -931,19 +942,18 @@ class _VideoPlayerTrackState extends State<_VideoPlayerTrack> { } } -class _VideoPlayerTorrentFiles extends StatefulWidget { - const _VideoPlayerTorrentFiles({ +class _TorrentFiles extends StatefulWidget { + const _TorrentFiles({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerTorrentFiles> createState() => - _VideoPlayerTorrentFilesState(); + State<_TorrentFiles> createState() => _TorrentFilesState(); } -class _VideoPlayerTorrentFilesState extends State<_VideoPlayerTorrentFiles> { +class _TorrentFilesState extends State<_TorrentFiles> { final controller = FlyoutController(); @override @@ -1004,18 +1014,18 @@ class _VideoPlayerTorrentFilesState extends State<_VideoPlayerTorrentFiles> { } } -class _VideoPlayerSpeed extends StatefulWidget { - const _VideoPlayerSpeed({ +class _Speed extends StatefulWidget { + const _Speed({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerSpeed> createState() => _VideoPlayerSpeedState(); + State<_Speed> createState() => _SpeedState(); } -class _VideoPlayerSpeedState extends State<_VideoPlayerSpeed> { +class _SpeedState extends State<_Speed> { final controller = FlyoutController(); @override @@ -1075,39 +1085,52 @@ class _VideoPlayerSpeedState extends State<_VideoPlayerSpeed> { } } -class _VideoPlayerProgress extends StatefulWidget { - const _VideoPlayerProgress({ +class _Progress extends StatefulWidget { + const _Progress({ required this.controller, }); final VideoPlayerController controller; @override - State<_VideoPlayerProgress> createState() => _VideoPlayerProgressState(); + State<_Progress> createState() => _ProgressState(); } -class _VideoPlayerProgressState extends State<_VideoPlayerProgress> { +class _ProgressState extends State<_Progress> { Duration position = const Duration(); Duration duration = const Duration(); bool _isDrag = false; + StreamSubscription? positionSubscription; + StreamSubscription? durationSubscription; @override void initState() { super.initState(); - widget.controller.player.stream.position.listen((event) { + positionSubscription = + widget.controller.player.stream.position.listen((event) { if (!_isDrag) { position = event; } }); - widget.controller.player.stream.duration.listen((event) { + durationSubscription = + widget.controller.player.stream.duration.listen((event) { duration = event; }); } + @override + void dispose() { + positionSubscription?.cancel(); + durationSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Slider( value: (position.inSeconds).toDouble(), - max: (duration.inSeconds).toDouble(), + max: duration.inSeconds < position.inSeconds + ? position.inSeconds.toDouble() + : duration.inSeconds.toDouble(), label: '${position.inMinutes}:${position.inSeconds % 60}', onChanged: (value) { _isDrag = true; diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart new file mode 100644 index 00000000..1353c0f7 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:miru_app/controllers/watch/video_controller.dart'; +import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/views/widgets/cache_network_image.dart'; +import 'package:miru_app/views/widgets/progress.dart'; + +class VideoPlayerMobileControls extends StatefulWidget { + const VideoPlayerMobileControls({super.key, required this.controller}); + final VideoPlayerController controller; + + @override + State createState() => + _VideoPlayerMobileControlsState(); +} + +class _VideoPlayerMobileControlsState extends State { + late final VideoPlayerController _c = widget.controller; + final _subtitleViewKey = GlobalKey(); + + bool _showControls = true; + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const SizedBox.expand(), + Positioned.fill( + child: Obx( + () { + final textStyle = TextStyle( + height: 1.4, + fontSize: _c.subtitleFontSize.value, + letterSpacing: 0.0, + wordSpacing: 0.0, + color: _c.subtitleFontColor.value, + fontWeight: _c.subtitleFontWeight.value, + backgroundColor: _c.subtitleBackgroundColor.value, + ); + _subtitleViewKey.currentState?.textAlign = + _c.subtitleTextAlign.value; + _subtitleViewKey.currentState?.style = textStyle; + _subtitleViewKey.currentState?.padding = EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + _showControls ? 100.0 : 16.0, + ); + return SubtitleView( + controller: _c.videoController, + configuration: SubtitleViewConfiguration( + style: textStyle, + textAlign: _c.subtitleTextAlign.value, + ), + key: _subtitleViewKey, + ); + }, + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + _showControls = !_showControls; + }); + }, + onDoubleTap: () { + if (_c.player.state.playing) { + _c.player.pause(); + } else { + _c.player.play(); + } + }, + // 左右滑动 + onHorizontalDragUpdate: (details) { + if (details.delta.dx > 0) { + _c.player.seek( + _c.player.state.position + const Duration(seconds: 1), + ); + } else { + _c.player.seek( + _c.player.state.position - const Duration(seconds: 1), + ); + } + }, + child: const SizedBox.expand(), + ), + ), + Positioned.fill( + child: Center( + child: Obx(() { + if (_c.error.value.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Getting streamlink error", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + child: const Text('Error message'), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error message'), + content: SelectableText(_c.error.value), + actions: [ + FilledButton( + child: Text('common.close'.i18n), + onPressed: () { + Get.back(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + FilledButton( + child: Text('Retry'.i18n), + onPressed: () { + _c.error.value = ''; + _c.play(); + }, + ), + ], + ) + ], + ); + } + if (!_c.isGettingWatchData.value) { + return StreamBuilder( + stream: _c.player.stream.buffering, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! || + _c.player.state.buffering) { + return const ProgressRing(); + } + return const SizedBox.shrink(); + }, + ); + } + return Card( + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_c.runtime.extension.icon != null) + Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(right: 10), + child: CacheNetWorkImagePic( + _c.runtime.extension.icon!, + width: 30, + height: 30, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _c.runtime.extension.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Getting streamlink...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) + ], + ), + ), + ); + }), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Opacity( + opacity: _showControls ? 1.0 : 0.0, + child: _Header( + controller: _c, + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Opacity( + opacity: _showControls ? 1.0 : 0.0, + child: _Footer(controller: _c), + ), + ), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.controller}); + final VideoPlayerController controller; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black, + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Get.back(); + }, + ), + Expanded( + child: Obx(() { + final data = controller.playList[controller.index.value]; + final episode = data.name; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + episode, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ); + }), + ), + // 设置按钮 + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + final show = controller.showSidebar.value; + controller.showSidebar.value = !show; + }, + ), + ], + ), + ); + } +} + +class _Footer extends StatelessWidget { + const _Footer({required this.controller}); + final VideoPlayerController controller; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black54, + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MaterialSeekBar(), + const SizedBox(height: 10), + Row( + children: [ + Obx( + () => IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: controller.index.value > 0 + ? () { + controller.index.value--; + } + : null, + ), + ), + StreamBuilder( + stream: controller.player.stream.playing, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!) { + return IconButton( + onPressed: controller.player.pause, + icon: const Icon( + Icons.pause, + size: 30, + ), + ); + } + return IconButton( + onPressed: controller.player.play, + icon: const Icon( + Icons.play_arrow, + size: 30, + ), + ); + }, + ), + Obx( + () => IconButton( + icon: const Icon(Icons.skip_next), + onPressed: + controller.playList.length - 1 > controller.index.value + ? () { + controller.index.value++; + } + : null, + ), + ), + // 播放进度 + StreamBuilder( + stream: controller.player.stream.position, + builder: (context, snapshot) { + if (snapshot.hasData) { + final position = snapshot.data as Duration; + return Text( + '${position.inMinutes}:${position.inSeconds % 60}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const Text('/'), + StreamBuilder( + stream: controller.player.stream.duration, + builder: (context, snapshot) { + if (snapshot.hasData) { + final duration = snapshot.data as Duration; + return Text( + '${duration.inMinutes}:${duration.inSeconds % 60}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index 14e48bc4..e8872a4d 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; @@ -26,7 +27,7 @@ class _VideoPlayerSidebarState extends State { selectIndex: _c.index.value, onChange: (value) { _c.index.value = value; - _c.showPlayList.value = false; + _c.showSidebar.value = false; }, ), "Settings": _SideBarSettings( @@ -34,10 +35,22 @@ class _VideoPlayerSidebarState extends State { ), }; - String _selectedTab = "Episodes"; - Widget _buildAndroid(BuildContext context) { - return _tabs[_selectedTab]!; + return DefaultTabController( + length: _tabs.length, + child: Column( + children: [ + TabBar( + tabs: _tabs.keys.map((e) => Tab(text: e)).toList(), + ), + Expanded( + child: TabBarView( + children: _tabs.values.toList(), + ), + ), + ], + ), + ); } Widget _buildDesktop(BuildContext context) { @@ -48,11 +61,22 @@ class _VideoPlayerSidebarState extends State { child: Container( color: fluent.FluentThemeData.dark().micaBackgroundColor, child: ListView( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), children: [ - Text( - "Settings", - style: fluent.FluentThemeData.dark().typography.bodyLarge, + Row( + children: [ + Text( + "Settings", + style: fluent.FluentThemeData.dark().typography.bodyLarge, + ), + const Spacer(), + fluent.IconButton( + onPressed: () { + _c.showSidebar.value = false; + }, + icon: const Icon(fluent.FluentIcons.chrome_close), + ), + ], ), const SizedBox(height: 20), _tabs["Settings"]! @@ -83,325 +107,420 @@ class _SideBarSettings extends StatefulWidget { class _SideBarSettingsState extends State<_SideBarSettings> { late final _c = widget.controller; - @override - Widget build(BuildContext context) { + + Widget _buildDesktop(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Subtitle'), - const SizedBox(height: 10), - Row( - children: [ - const Text('Font size'), - const SizedBox(width: 10), - Expanded( - child: Obx( - () => fluent.Slider( - value: _c.subtitleFontSize.value, - onChanged: (value) { - _c.subtitleFontSize.value = value; - }, - min: 20, - max: 80, - ), - ), - ), - const SizedBox(width: 10), - Obx( - () => Text( - _c.subtitleFontSize.value.toStringAsFixed(0), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - const Text('Font color'), - const SizedBox(width: 10), - fluent.SplitButton( - flyout: fluent.FlyoutContent( - constraints: const BoxConstraints(maxWidth: 200.0), - child: Obx( - () => Wrap( - runSpacing: 10.0, - spacing: 8.0, - children: [ - fluent.Button( - autofocus: _c.subtitleTextColor.value == Colors.white, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleTextColor.value = Colors.white; - Navigator.of(context).pop(Colors.white); + fluent.Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Subtitle'), + const SizedBox(height: 10), + Row( + children: [ + const Text('Font size'), + const SizedBox(width: 10), + Expanded( + child: Obx( + () => fluent.Slider( + value: _c.subtitleFontSize.value, + onChanged: (value) { + _c.subtitleFontSize.value = value; }, - child: Container( - height: 32, - width: 32, - color: Colors.white, - ), + min: 20, + max: 80, ), - ...fluent.Colors.accentColors.map((color) { - return fluent.Button( - autofocus: _c.subtitleTextColor.value == color, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleTextColor.value = color; - Navigator.of(context).pop(color); - }, - child: Container( - height: 32, - width: 32, - color: color, - ), - ); - }), - ], + ), ), - ), - ), - child: Obx( - () => Container( - decoration: BoxDecoration( - color: _c.subtitleTextColor.value, - borderRadius: const BorderRadiusDirectional.horizontal( - start: Radius.circular(4.0), + const SizedBox(width: 10), + Obx( + () => Text( + _c.subtitleFontSize.value.toStringAsFixed(0), ), ), - height: 32, - width: 36, - ), + ], ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - const Text('Background color'), - const SizedBox(width: 10), - fluent.SplitButton( - flyout: fluent.FlyoutContent( - constraints: const BoxConstraints(maxWidth: 200.0), - child: Obx( - () => Wrap( - runSpacing: 10.0, - spacing: 8.0, - children: [ - fluent.Button( - autofocus: _c.subtitleBackgroundColor.value == - Colors.transparent, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleBackgroundColor.value = Colors.transparent; - Navigator.of(context).pop(Colors.transparent); - }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, + const SizedBox(height: 10), + Row( + children: [ + const Text('Font color'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: + _c.subtitleFontColor.value == Colors.white, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleFontColor.value = Colors.white; + Navigator.of(context).pop(Colors.white); + }, + child: Container( + height: 32, + width: 32, + color: Colors.white, + ), + ), + ...fluent.Colors.accentColors.map((color) { + return fluent.Button( + autofocus: _c.subtitleFontColor.value == color, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleFontColor.value = color; + Navigator.of(context).pop(color); + }, + child: Container( + height: 32, + width: 32, + color: color, + ), + ); + }), + ], ), ), - ...fluent.Colors.accentColors.map((color) { - return fluent.Button( - autofocus: _c.subtitleBackgroundColor.value == color, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleBackgroundColor.value = color; - Navigator.of(context).pop(color); - }, - child: Container( - height: 32, - width: 32, - color: color, + ), + child: Obx( + () => Container( + decoration: BoxDecoration( + color: _c.subtitleFontColor.value, + borderRadius: + const BorderRadiusDirectional.horizontal( + start: Radius.circular(4.0), ), - ); - }), - ], - ), - ), - ), - child: Obx( - () => Container( - decoration: BoxDecoration( - color: _c.subtitleBackgroundColor.value, - borderRadius: const BorderRadiusDirectional.horizontal( - start: Radius.circular(4.0), + ), + height: 32, + width: 36, + ), ), ), - height: 32, - width: 36, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - const Text('Background opacity'), - const SizedBox(width: 10), - Expanded( - child: Obx( - () => fluent.Slider( - value: _c.subtitleBackgroundColor.value.opacity, - onChanged: (value) { - _c.subtitleBackgroundColor.value = - _c.subtitleBackgroundColor.value.withOpacity(value); - }, - min: 0, - max: 1, - ), - ), - ), - const SizedBox(width: 10), - Obx( - () => Text( - _c.subtitleBackgroundColor.value.opacity.toStringAsFixed(2), + ], ), - ), - ], - ), - const SizedBox(height: 10), - // textAlign - Row( - children: [ - const Text('Text align'), - const SizedBox(width: 10), - fluent.SplitButton( - flyout: fluent.FlyoutContent( - constraints: const BoxConstraints(maxWidth: 200.0), - child: Obx( - () => Wrap( - runSpacing: 10.0, - spacing: 8.0, - children: [ - fluent.Button( - autofocus: - _c.subtitleTextAlign.value == TextAlign.justify, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleTextAlign.value = TextAlign.justify; - Navigator.of(context).pop(TextAlign.justify); - }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, - child: const Icon( - Icons.format_align_justify, - color: Colors.white, - ), + const SizedBox(height: 10), + Row( + children: [ + const Text('Background color'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: _c.subtitleBackgroundColor.value == + Colors.transparent, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleBackgroundColor.value = + Colors.transparent; + Navigator.of(context).pop(Colors.transparent); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + ), + ), + ...fluent.Colors.accentColors.map((color) { + return fluent.Button( + autofocus: + _c.subtitleBackgroundColor.value == color, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleBackgroundColor.value = color; + Navigator.of(context).pop(color); + }, + child: Container( + height: 32, + width: 32, + color: color, + ), + ); + }), + ], ), ), - fluent.Button( - autofocus: _c.subtitleTextAlign.value == TextAlign.left, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleTextAlign.value = TextAlign.left; - Navigator.of(context).pop(TextAlign.left); - }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, - child: const Icon( - Icons.format_align_left, - color: Colors.white, + ), + child: Obx( + () => Container( + decoration: BoxDecoration( + color: _c.subtitleBackgroundColor.value, + borderRadius: + const BorderRadiusDirectional.horizontal( + start: Radius.circular(4.0), ), ), + height: 32, + width: 36, ), - fluent.Button( - autofocus: - _c.subtitleTextAlign.value == TextAlign.right, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleTextAlign.value = TextAlign.right; - Navigator.of(context).pop(TextAlign.right); + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Text('Background opacity'), + const SizedBox(width: 10), + Expanded( + child: Obx( + () => fluent.Slider( + value: _c.subtitleBackgroundColor.value.opacity, + onChanged: (value) { + _c.subtitleBackgroundColor.value = _c + .subtitleBackgroundColor.value + .withOpacity(value); }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, - child: const Icon( - Icons.format_align_right, - color: Colors.white, - ), - ), + min: 0, + max: 1, ), - fluent.Button( - autofocus: - _c.subtitleTextAlign.value == TextAlign.center, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), + ), + ), + const SizedBox(width: 10), + Obx( + () => Text( + _c.subtitleBackgroundColor.value.opacity + .toStringAsFixed(2), + ), + ), + ], + ), + const SizedBox(height: 10), + // textAlign + Row( + children: [ + const Text('Text align'), + const SizedBox(width: 10), + fluent.SplitButton( + flyout: fluent.FlyoutContent( + constraints: const BoxConstraints(maxWidth: 200.0), + child: Obx( + () => Wrap( + runSpacing: 10.0, + spacing: 8.0, + children: [ + fluent.Button( + autofocus: _c.subtitleTextAlign.value == + TextAlign.justify, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.justify; + Navigator.of(context).pop(TextAlign.justify); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_justify, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: + _c.subtitleTextAlign.value == TextAlign.left, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.left; + Navigator.of(context).pop(TextAlign.left); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_left, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: + _c.subtitleTextAlign.value == TextAlign.right, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.right; + Navigator.of(context).pop(TextAlign.right); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_right, + color: Colors.white, + ), + ), + ), + fluent.Button( + autofocus: _c.subtitleTextAlign.value == + TextAlign.center, + style: fluent.ButtonStyle( + padding: fluent.ButtonState.all( + const EdgeInsets.all(4.0), + ), + ), + onPressed: () { + _c.subtitleTextAlign.value = TextAlign.center; + Navigator.of(context).pop(TextAlign.center); + }, + child: Container( + height: 32, + width: 32, + color: Colors.transparent, + child: const Icon( + Icons.format_align_center, + color: Colors.white, + ), + ), + ), + ], ), - onPressed: () { - _c.subtitleTextAlign.value = TextAlign.center; - Navigator.of(context).pop(TextAlign.center); - }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, - child: const Icon( - Icons.format_align_center, - color: Colors.white, - ), + ), + ), + child: Obx( + () => SizedBox( + height: 32, + width: 36, + child: Icon( + _c.subtitleTextAlign.value == TextAlign.justify + ? Icons.format_align_justify + : _c.subtitleTextAlign.value == TextAlign.left + ? Icons.format_align_left + : _c.subtitleTextAlign.value == + TextAlign.right + ? Icons.format_align_right + : Icons.format_align_center, + color: Colors.white, ), ), - ], + ), ), + ], + ), + const SizedBox(height: 10), + const Text("Font weight"), + const SizedBox(height: 10), + Obx( + () => Wrap( + spacing: 5, + runSpacing: 5, + children: [ + fluent.ToggleButton( + checked: _c.subtitleFontWeight.value == FontWeight.normal, + onChanged: (value) { + _c.subtitleFontWeight.value = FontWeight.normal; + }, + child: const Text("Normal"), + ), + fluent.ToggleButton( + checked: _c.subtitleFontWeight.value == FontWeight.bold, + onChanged: (value) { + _c.subtitleFontWeight.value = FontWeight.bold; + }, + child: const Text("Bold"), + ), + ], ), ), - child: Obx( - () => SizedBox( - height: 32, - width: 36, - child: Icon( - _c.subtitleTextAlign.value == TextAlign.justify - ? Icons.format_align_justify - : _c.subtitleTextAlign.value == TextAlign.left - ? Icons.format_align_left - : _c.subtitleTextAlign.value == TextAlign.right - ? Icons.format_align_right - : Icons.format_align_center, - color: Colors.white, - ), + ], + ), + ), + const SizedBox(height: 20), + fluent.Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Play mode"), + const SizedBox( + height: 10, + width: double.infinity, + ), + Obx( + () => Wrap( + spacing: 5, + runSpacing: 5, + children: [ + fluent.ToggleButton( + checked: _c.playMode.value == PlaylistMode.loop, + onChanged: (value) { + _c.playMode.value = PlaylistMode.loop; + }, + child: const Text("Loop"), + ), + fluent.ToggleButton( + checked: _c.playMode.value == PlaylistMode.single, + onChanged: (value) { + _c.playMode.value = PlaylistMode.single; + }, + child: const Text("Single"), + ), + fluent.ToggleButton( + checked: _c.playMode.value == PlaylistMode.none, + onChanged: (value) { + _c.playMode.value = PlaylistMode.none; + }, + child: const Text("Auto next"), + ), + ], ), ), - ), - ], + ], + ), ), ], ); } + + Widget _buildAndroid(BuildContext context) { + return Container(); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } } From 8c40dc9d6b3758d0ee671bbca893f49f62f6d5f9 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Thu, 1 Feb 2024 18:23:34 +0800 Subject: [PATCH 04/19] Fix video player bugs and improve UI --- lib/controllers/watch/video_controller.dart | 165 ++++-- lib/views/pages/watch/video/video_player.dart | 5 +- .../video/video_player_desktop_controls.dart | 28 +- .../video/video_player_mobile_controls.dart | 542 ++++++++++++------ .../watch/video/video_player_sidebar.dart | 165 +++++- 5 files changed, 633 insertions(+), 272 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 5e8e9441..92e6991c 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; @@ -26,6 +27,7 @@ import 'package:miru_app/data/services/extension_service.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/utils/layout.dart'; import 'package:miru_app/utils/miru_directory.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:window_manager/window_manager.dart'; import 'package:path/path.dart' as path; import 'package:fluent_ui/fluent_ui.dart' as fluent; @@ -121,7 +123,7 @@ class VideoPlayerController extends GetxController { final subtitles = [].obs; // 画质 - final currentQality = "".obs; + final currentQuality = "".obs; final qualityMap = {}; // 是否已经自动跳转到上次播放进度 @@ -152,6 +154,9 @@ class VideoPlayerController extends GetxController { final subtitleFontColor = Colors.white.obs; final subtitleBackgroundColor = const Color(0xaa000000).obs; + // 侧边栏初始化 tab + final initSidebarTab = SidebarTab.episodes.obs; + // 播放方式 final playMode = PlaylistMode.none.obs; @@ -164,14 +169,6 @@ class VideoPlayerController extends GetxController { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } - if (player.platform is NativePlayer) { - await (player.platform as dynamic).setProperty('cache', 'yes'); - await (player.platform as dynamic) - .setProperty('demuxer-readahead-secs', '20'); - await (player.platform as dynamic) - .setProperty('demuxer-max-bytes', '30MiB'); - } - // 切换剧集 ever(index, (callback) { play(); @@ -213,7 +210,7 @@ class VideoPlayerController extends GetxController { player.stream.height.listen((event) async { if (player.state.width != null) { final width = player.state.width; - currentQality.value = "${width}x$event"; + currentQuality.value = "${width}x$event"; } }); @@ -372,25 +369,71 @@ class VideoPlayerController extends GetxController { getQuality() async { final url = watchData!.url; final headers = watchData!.headers; + logger.info(url); + final response = await dio.get( + url, + options: Options( + headers: headers, + responseType: ResponseType.stream, + ), + ); + + // 请求判断 content-type 是否为 m3u8 + final contentType = response.headers.value('content-type'); + if (contentType == null || !contentType.contains('mpegurl')) { + logger.info('not m3u8'); + return; + } + + // 接收数据到变量 + final completer = Completer(); + + final stream = response.data.stream; + final buffer = StringBuffer(); + + stream.listen( + (data) { + buffer.write(utf8.decode(data)); + }, + onDone: () { + final m3u8Content = buffer.toString(); + completer.complete(m3u8Content); + }, + onError: (error) { + completer.completeError(error); + }, + ); + + final m3u8Content = await completer.future; + if (m3u8Content.isEmpty) { + return; + } + late HlsPlaylist playlist; try { - final response = await dio.get( - url, - options: Options( - headers: headers, + playlist = await HlsPlaylistParser.create().parseString( + response.realUri, + m3u8Content, + ); + } on ParserException catch (e) { + logger.severe(e); + return; + } + + if (playlist is HlsMasterPlaylist) { + final urlList = playlist.mediaPlaylistUrls + .map( + (e) => e.toString(), + ) + .toList(); + final resolution = playlist.variants.map( + (it) => "${it.format.width}x${it.format.height}", + ); + qualityMap.addAll( + Map.fromIterables( + resolution, + urlList, ), ); - final playList = await HlsPlaylistParser.create().parseString( - Uri.parse(url), - response.data, - ) as HlsMasterPlaylist; - List urlList = - playList.mediaPlaylistUrls.map((e) => e.toString()).toList(); - final resolution = playList.variants - .map((it) => "${it.format.width}x${it.format.height}"); - logger.info("get sources"); - qualityMap.addAll(Map.fromIterables(resolution, urlList)); - } catch (error) { - logger.severe(error); } } @@ -440,27 +483,25 @@ class VideoPlayerController extends GetxController { file.deleteSync(recursive: true); } - player.screenshot().then((value) { - file.writeAsBytes(value!).then( - (value) async { - debugPrint("save.. ${value.path}"); - await DatabaseService.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(); - }, - ); - }); + final data = await player.screenshot(); + if (data == null) { + return; + } + await file.writeAsBytes(data); + await DatabaseService.putHistory( + History() + ..url = detailUrl + ..cover = file.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(); } _isSubtitle(String file) { @@ -491,6 +532,33 @@ class VideoPlayerController extends GetxController { _processNextMessage(); } + toggleSideBar(SidebarTab tab) { + if (showSidebar.value) { + showSidebar.value = false; + return; + } + initSidebarTab.value = tab; + showSidebar.value = true; + } + + addSubtitleFile() async { + final file = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['srt', 'vtt'], + allowMultiple: false, + ); + if (file == null) { + return; + } + final data = File(file.files.first.path!).readAsStringSync(); + subtitles.add( + SubtitleTrack.data( + data, + title: file.files.first.name, + ), + ); + } + @override void onClose() { if (MiruStorage.getSetting(SettingKey.autoTracking) && anilistID != "") { @@ -500,6 +568,8 @@ class VideoPlayerController extends GetxController { mediaId: anilistID, ); } + player.stop(); + player.dispose(); if (Platform.isAndroid) { SystemChrome.setEnabledSystemUIMode( @@ -515,7 +585,6 @@ class VideoPlayerController extends GetxController { DeviceOrientation.portraitDown, ]); } - super.onClose(); } } diff --git a/lib/views/pages/watch/video/video_player.dart b/lib/views/pages/watch/video/video_player.dart index 15636dfb..673b652b 100644 --- a/lib/views/pages/watch/video/video_player.dart +++ b/lib/views/pages/watch/video/video_player.dart @@ -54,7 +54,6 @@ class _VideoPlayerState extends State { @override void dispose() { - _c.player.dispose(); Get.delete(tag: widget.title); super.dispose(); } @@ -63,8 +62,8 @@ class _VideoPlayerState extends State { return Obx(() { final maxWidth = MediaQuery.of(context).size.width; return PopScope( - onPopInvoked: (_) async { - await _c.onExit(); + onPopInvoked: (_) { + _c.onExit(); }, child: Row( children: [ diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index 50465284..b2ce4893 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; @@ -399,7 +397,7 @@ class _Footer extends StatelessWidget { ), // 画质 Obx(() { - if (controller.currentQality.value.isEmpty) { + if (controller.currentQuality.value.isEmpty) { return const SizedBox.shrink(); } return Padding( @@ -732,7 +730,7 @@ class _QualityState extends State<_Quality> { return FlyoutTarget( controller: controller, child: Button( - child: Text(widget.controller.currentQality.value), + child: Text(widget.controller.currentQuality.value), onPressed: () { if (widget.controller.qualityMap.isEmpty) { widget.controller.sendMessage( @@ -793,24 +791,6 @@ class _Track extends StatefulWidget { class _TrackState extends State<_Track> { final controller = FlyoutController(); - _addSubtitle() async { - final file = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['srt', 'vtt'], - allowMultiple: false, - ); - if (file == null) { - return; - } - final data = File(file.files.first.path!).readAsStringSync(); - widget.controller.subtitles.add( - SubtitleTrack.data( - data, - title: file.files.first.name, - ), - ); - } - @override dispose() { super.dispose(); @@ -866,7 +846,7 @@ class _TrackState extends State<_Track> { ListTile.selectable( title: const Text('Add subtitle file'), onPressed: () { - _addSubtitle(); + widget.controller.addSubtitleFile(); }, ), // 来自扩展的字幕 @@ -905,7 +885,7 @@ class _TrackState extends State<_Track> { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( - "Audio Tracks", + "Audio", style: TextStyle( fontSize: 13, color: Colors.white.withAlpha(200), diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 1353c0f7..9b6c6c72 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/utils/router.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/progress.dart'; @@ -18,206 +23,220 @@ class VideoPlayerMobileControls extends StatefulWidget { class _VideoPlayerMobileControlsState extends State { late final VideoPlayerController _c = widget.controller; final _subtitleViewKey = GlobalKey(); - bool _showControls = true; + @override Widget build(BuildContext context) { - return Stack( - children: [ - const SizedBox.expand(), - Positioned.fill( - child: Obx( - () { - final textStyle = TextStyle( - height: 1.4, - fontSize: _c.subtitleFontSize.value, - letterSpacing: 0.0, - wordSpacing: 0.0, - color: _c.subtitleFontColor.value, - fontWeight: _c.subtitleFontWeight.value, - backgroundColor: _c.subtitleBackgroundColor.value, - ); - _subtitleViewKey.currentState?.textAlign = - _c.subtitleTextAlign.value; - _subtitleViewKey.currentState?.style = textStyle; - _subtitleViewKey.currentState?.padding = EdgeInsets.fromLTRB( - 16.0, - 0.0, - 16.0, - _showControls ? 100.0 : 16.0, - ); - return SubtitleView( - controller: _c.videoController, - configuration: SubtitleViewConfiguration( - style: textStyle, - textAlign: _c.subtitleTextAlign.value, - ), - key: _subtitleViewKey, - ); - }, - ), - ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - setState(() { - _showControls = !_showControls; - }); - }, - onDoubleTap: () { - if (_c.player.state.playing) { - _c.player.pause(); - } else { - _c.player.play(); - } - }, - // 左右滑动 - onHorizontalDragUpdate: (details) { - if (details.delta.dx > 0) { - _c.player.seek( - _c.player.state.position + const Duration(seconds: 1), - ); - } else { - _c.player.seek( - _c.player.state.position - const Duration(seconds: 1), - ); - } - }, - child: const SizedBox.expand(), - ), - ), - Positioned.fill( - child: Center( - child: Obx(() { - if (_c.error.value.isNotEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "Getting streamlink error", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + return DefaultTextStyle( + style: const TextStyle( + color: Colors.white, + ), + child: Theme( + data: ThemeData.dark(useMaterial3: true), + child: Stack( + children: [ + const SizedBox.expand(), + Positioned.fill( + child: Obx( + () { + final textStyle = TextStyle( + height: 1.4, + fontSize: _c.subtitleFontSize.value, + letterSpacing: 0.0, + wordSpacing: 0.0, + color: _c.subtitleFontColor.value, + fontWeight: _c.subtitleFontWeight.value, + backgroundColor: _c.subtitleBackgroundColor.value, + ); + _subtitleViewKey.currentState?.textAlign = + _c.subtitleTextAlign.value; + _subtitleViewKey.currentState?.style = textStyle; + _subtitleViewKey.currentState?.padding = EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + _showControls ? 100.0 : 16.0, + ); + return SubtitleView( + controller: _c.videoController, + configuration: SubtitleViewConfiguration( + style: textStyle, + textAlign: _c.subtitleTextAlign.value, ), - const SizedBox(height: 10), - Row( + key: _subtitleViewKey, + ); + }, + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + _showControls = !_showControls; + }); + }, + onDoubleTap: () { + if (_c.player.state.playing) { + _c.player.pause(); + } else { + _c.player.play(); + } + }, + // 左右滑动 + onHorizontalDragUpdate: (details) { + if (details.delta.dx > 0) { + _c.player.seek( + _c.player.state.position + const Duration(seconds: 1), + ); + } else { + _c.player.seek( + _c.player.state.position - const Duration(seconds: 1), + ); + } + }, + onLongPress: () { + _c.player.setRate(3.0); + }, + onLongPressEnd: (details) { + _c.player.setRate(_c.currentSpeed.value); + }, + child: const SizedBox.expand(), + ), + ), + Positioned.fill( + child: Center( + child: Obx(() { + if (_c.error.value.isNotEmpty) { + return Column( mainAxisSize: MainAxisSize.min, children: [ - FilledButton( - child: const Text('Error message'), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Error message'), - content: SelectableText(_c.error.value), - actions: [ - FilledButton( - child: Text('common.close'.i18n), - onPressed: () { - Get.back(); - }, - ), - ], - ), - ); - }, - ), - const SizedBox(width: 10), - FilledButton( - child: Text('Retry'.i18n), - onPressed: () { - _c.error.value = ''; - _c.play(); - }, - ), - ], - ) - ], - ); - } - if (!_c.isGettingWatchData.value) { - return StreamBuilder( - stream: _c.player.stream.buffering, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! || - _c.player.state.buffering) { - return const ProgressRing(); - } - return const SizedBox.shrink(); - }, - ); - } - return Card( - elevation: 0, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_c.runtime.extension.icon != null) - Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - ), - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.only(right: 10), - child: CacheNetWorkImagePic( - _c.runtime.extension.icon!, - width: 30, - height: 30, + const Text( + "Getting streamlink error", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, ), ), - Column( + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + child: const Text('Error message'), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error message'), + content: SelectableText(_c.error.value), + actions: [ + FilledButton( + child: Text('common.close'.i18n), + onPressed: () { + Get.back(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + FilledButton( + child: Text('Retry'.i18n), + onPressed: () { + _c.error.value = ''; + _c.play(); + }, + ), + ], + ) + ], + ); + } + if (!_c.isGettingWatchData.value) { + return StreamBuilder( + stream: _c.player.stream.buffering, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! || + _c.player.state.buffering) { + return const ProgressRing(); + } + return const SizedBox.shrink(); + }, + ); + } + return Card( + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _c.runtime.extension.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Text( - 'Getting streamlink...', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, + if (_c.runtime.extension.icon != null) + Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(right: 10), + child: CacheNetWorkImagePic( + _c.runtime.extension.icon!, + width: 30, + height: 30, + ), ), - ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _c.runtime.extension.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Getting streamlink...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) ], - ) - ], - ), + ), + ), + ); + }), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Opacity( + opacity: _showControls ? 1.0 : 0.0, + child: _Header( + controller: _c, ), - ); - }), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Opacity( - opacity: _showControls ? 1.0 : 0.0, - child: _Header( - controller: _c, + ), ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Opacity( - opacity: _showControls ? 1.0 : 0.0, - child: _Footer(controller: _c), - ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Opacity( + opacity: _showControls ? 1.0 : 0.0, + child: _Footer(controller: _c), + ), + ), + ], ), - ], + ), ); } } @@ -234,7 +253,7 @@ class _Header extends StatelessWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black, + Colors.black54, Colors.transparent, ], ), @@ -245,9 +264,10 @@ class _Header extends StatelessWidget { IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { - Get.back(); + RouterUtils.pop(); }, ), + const SizedBox(width: 10), Expanded( child: Obx(() { final data = controller.playList[controller.index.value]; @@ -277,8 +297,7 @@ class _Header extends StatelessWidget { IconButton( icon: const Icon(Icons.settings), onPressed: () { - final show = controller.showSidebar.value; - controller.showSidebar.value = !show; + controller.toggleSideBar(SidebarTab.settings); }, ), ], @@ -308,7 +327,7 @@ class _Footer extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const MaterialSeekBar(), + _SeekBar(controller: controller), const SizedBox(height: 10), Row( children: [ @@ -354,6 +373,7 @@ class _Footer extends StatelessWidget { : null, ), ), + const SizedBox(width: 10), // 播放进度 StreamBuilder( stream: controller.player.stream.position, @@ -388,6 +408,48 @@ class _Footer extends StatelessWidget { return const SizedBox.shrink(); }, ), + const Spacer(), + // 倍速 + Obx( + () => PopupMenuButton( + initialValue: controller.currentSpeed.value, + onSelected: (value) { + controller.currentSpeed.value = value; + }, + itemBuilder: (context) { + return [ + for (final speed in controller.speedList) + PopupMenuItem( + value: speed, + child: Text('${speed}x'), + ), + ]; + }, + child: Text( + '${controller.currentSpeed.value}x', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ), + ), + const SizedBox(width: 10), + IconButton( + onPressed: () { + controller.toggleSideBar(SidebarTab.tracks); + }, + icon: const Icon( + Icons.subtitles, + ), + ), + // 播放列表 + IconButton( + icon: const Icon(Icons.playlist_play), + onPressed: () { + controller.toggleSideBar(SidebarTab.episodes); + }, + ), ], ), ], @@ -395,3 +457,103 @@ class _Footer extends StatelessWidget { ); } } + +class _SeekBar extends StatefulWidget { + const _SeekBar({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + State<_SeekBar> createState() => _SeekBarState(); +} + +class _SeekBarState extends State<_SeekBar> { + bool _isSliderDraging = false; + Duration _duration = Duration.zero; + Duration _position = Duration.zero; + Duration _buffer = Duration.zero; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _bufferSubscription; + + @override + void initState() { + super.initState(); + _durationSubscription = + widget.controller.player.stream.duration.listen((event) { + setState(() { + _duration = event; + }); + }); + _positionSubscription = + widget.controller.player.stream.position.listen((event) { + if (!_isSliderDraging) { + setState(() { + _position = event; + }); + } + }); + _bufferSubscription = + widget.controller.player.stream.buffer.listen((event) { + setState(() { + _buffer = event; + }); + }); + } + + @override + dispose() { + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _bufferSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: const SliderThemeData( + trackHeight: 2, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: Slider( + min: 0, + max: _duration.inMilliseconds.toDouble(), + value: clampDouble( + _position.inMilliseconds.toDouble(), + 0, + _duration.inMilliseconds.toDouble(), + ), + secondaryTrackValue: clampDouble( + _buffer.inMilliseconds.toDouble(), + 0, + _duration.inMilliseconds.toDouble(), + ), + onChanged: (value) { + if (_isSliderDraging) { + setState(() { + _position = Duration(milliseconds: value.toInt()); + }); + } + }, + onChangeStart: (value) { + _isSliderDraging = true; + }, + onChangeEnd: (value) { + if (_isSliderDraging) { + widget.controller.player.seek( + Duration(milliseconds: value.toInt()), + ); + _isSliderDraging = false; + } + }, + )); + } +} diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index e8872a4d..d7ee622e 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -6,6 +6,14 @@ import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; +enum SidebarTab { + episodes, + qualitys, + torrentFiles, + tracks, + settings, +} + class VideoPlayerSidebar extends StatefulWidget { const VideoPlayerSidebar({ super.key, @@ -20,8 +28,8 @@ class VideoPlayerSidebar extends StatefulWidget { class _VideoPlayerSidebarState extends State { late final _c = widget.controller; - late final Map _tabs = { - "Episodes": PlayList( + late final Map _tabs = { + SidebarTab.episodes: PlayList( title: _c.title, list: _c.playList.map((e) => e.name).toList(), selectIndex: _c.index.value, @@ -30,18 +38,19 @@ class _VideoPlayerSidebarState extends State { _c.showSidebar.value = false; }, ), - "Settings": _SideBarSettings( - controller: _c, - ), }; Widget _buildAndroid(BuildContext context) { return DefaultTabController( length: _tabs.length, + initialIndex: !_tabs.keys.toList().contains(_c.initSidebarTab.value) + ? 0 + : _tabs.keys.toList().indexOf(_c.initSidebarTab.value), child: Column( children: [ TabBar( - tabs: _tabs.keys.map((e) => Tab(text: e)).toList(), + isScrollable: true, + tabs: _tabs.keys.map((e) => Tab(text: e.name)).toList(), ), Expanded( child: TabBarView( @@ -79,7 +88,7 @@ class _VideoPlayerSidebarState extends State { ], ), const SizedBox(height: 20), - _tabs["Settings"]! + _tabs[SidebarTab.settings]! ], ), ), @@ -88,6 +97,26 @@ class _VideoPlayerSidebarState extends State { @override Widget build(BuildContext context) { + if (_c.qualityMap.isNotEmpty) { + _tabs.addAll( + { + SidebarTab.qualitys: _QualitySelector( + controller: _c, + ), + }, + ); + } + + _tabs.addAll( + { + SidebarTab.tracks: _TrackSelector( + controller: _c, + ), + SidebarTab.settings: _SideBarSettings( + controller: _c, + ), + }, + ); return PlatformBuildWidget( androidBuilder: _buildAndroid, desktopBuilder: _buildDesktop, @@ -524,3 +553,125 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ); } } + +class _QualitySelector extends StatefulWidget { + const _QualitySelector({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + State<_QualitySelector> createState() => _QualitySelectorState(); +} + +class _QualitySelectorState extends State<_QualitySelector> { + late final _c = widget.controller; + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + for (final quality in _c.qualityMap.entries) + ListTile( + onTap: () { + _c.switchQuality(quality.value); + _c.showSidebar.value = false; + }, + title: Text( + quality.key, + ), + ), + ], + ); + } +} + +class _TrackSelector extends StatelessWidget { + const _TrackSelector({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.symmetric(vertical: 10), + children: [ + const Padding( + padding: EdgeInsets.only(left: 16), + child: Text( + "Subtitle", + ), + ), + const SizedBox(height: 5), + ListTile( + selected: + SubtitleTrack.no() == controller.player.state.track.subtitle, + title: const Text('Off'), + onTap: () { + controller.player.setSubtitleTrack( + SubtitleTrack.no(), + ); + controller.showSidebar.value = false; + }, + ), + ListTile( + title: const Text('Add subtitle file'), + onTap: () { + controller.addSubtitleFile(); + controller.showSidebar.value = false; + }, + ), + // 来自扩展的字幕 + for (final subtitle in controller.subtitles) + ListTile( + selected: subtitle == controller.player.state.track.subtitle, + title: Text(subtitle.title ?? ''), + subtitle: Text(subtitle.language ?? ''), + onTap: () { + controller.player.setSubtitleTrack( + subtitle, + ); + controller.showSidebar.value = false; + }, + ), + // 来自视频本身的字幕 + for (final subtitle in controller.player.state.tracks.subtitle) + if (subtitle != SubtitleTrack.no() && + (subtitle.language != null || subtitle.title != null)) + ListTile( + selected: subtitle == controller.player.state.track.subtitle, + title: Text(subtitle.title ?? ''), + subtitle: Text(subtitle.language ?? ''), + onTap: () { + controller.player.setSubtitleTrack( + subtitle, + ); + controller.showSidebar.value = false; + }, + ), + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.only(left: 16), + child: Text( + "Audio", + ), + ), + const SizedBox(height: 5), + for (final audio in controller.player.state.tracks.audio) + if (audio.language != null || audio.title != null) + ListTile( + selected: audio == controller.player.state.track.audio, + title: Text(audio.title ?? ''), + subtitle: Text(audio.language ?? ''), + onTap: () { + controller.player.setAudioTrack( + audio, + ); + controller.showSidebar.value = false; + }, + ), + ], + ); + } +} From 5b144dc51fd22979cd3cc34ca405cae4b40077c4 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Fri, 2 Feb 2024 11:52:40 +0800 Subject: [PATCH 05/19] Refactor video controller and video player sidebar --- lib/controllers/watch/video_controller.dart | 32 ++--- lib/views/pages/watch/video/video_player.dart | 97 +++++++------- .../video/video_player_desktop_controls.dart | 79 ++++++------ .../video/video_player_mobile_controls.dart | 120 ++++++++++++------ .../watch/video/video_player_sidebar.dart | 54 ++++++-- 5 files changed, 222 insertions(+), 160 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 92e6991c..cf917673 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -463,11 +463,7 @@ class VideoPlayerController extends GetxController { }); } - onExit() async { - if (_torrenHash.isNotEmpty) { - BTServerApi.removeTorrent(_torrenHash); - } - + _saveHistory() async { if (player.state.duration.inSeconds == 0) { return; } @@ -488,6 +484,9 @@ class VideoPlayerController extends GetxController { return; } await file.writeAsBytes(data); + + logger.info('save history'); + await DatabaseService.putHistory( History() ..url = detailUrl @@ -560,7 +559,7 @@ class VideoPlayerController extends GetxController { } @override - void onClose() { + void onClose() async { if (MiruStorage.getSetting(SettingKey.autoTracking) && anilistID != "") { AniListProvider.editList( status: AnilistMediaListStatus.current, @@ -568,23 +567,24 @@ class VideoPlayerController extends GetxController { mediaId: anilistID, ); } - player.stop(); - player.dispose(); - if (Platform.isAndroid) { SystemChrome.setEnabledSystemUIMode( SystemUiMode.edgeToEdge, ); // 如果是平板则不改变 - if (LayoutUtils.isTablet) { - return; - } // 切换回竖屏 - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); + if (!LayoutUtils.isTablet) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } } + player.pause(); + try { + await _saveHistory(); + } catch (_) {} + player.dispose(); super.onClose(); } } diff --git a/lib/views/pages/watch/video/video_player.dart b/lib/views/pages/watch/video/video_player.dart index 673b652b..08a790a4 100644 --- a/lib/views/pages/watch/video/video_player.dart +++ b/lib/views/pages/watch/video/video_player.dart @@ -61,60 +61,55 @@ class _VideoPlayerState extends State { _buildContent() { return Obx(() { final maxWidth = MediaQuery.of(context).size.width; - return PopScope( - onPopInvoked: (_) { - _c.onExit(); - }, - child: Row( - children: [ - AnimatedContainer( - onEnd: () { - _c.isOpenSidebar.value = _c.showSidebar.value; - }, - width: _c.showSidebar.value - ? MediaQuery.of(context).size.width - 300 - : maxWidth, - duration: const Duration(milliseconds: 120), - child: Stack( - children: [ - 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), - ), + return Row( + children: [ + AnimatedContainer( + onEnd: () { + _c.isOpenSidebar.value = _c.showSidebar.value; + }, + width: _c.showSidebar.value + ? MediaQuery.of(context).size.width - 300 + : maxWidth, + duration: const Duration(milliseconds: 120), + child: Stack( + children: [ + 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), ), - constraints: BoxConstraints( - maxHeight: 200, - maxWidth: maxWidth, + ), + constraints: BoxConstraints( + maxHeight: 200, + maxWidth: maxWidth, + ), + child: DefaultTextStyle( + style: const TextStyle( + color: Colors.white, ), - child: DefaultTextStyle( - style: const TextStyle( - color: Colors.white, - ), - child: _c.cuurentMessageWidget.value!, - ), - ).animate().fade(), - ), - ], - ), + child: _c.cuurentMessageWidget.value!, + ), + ).animate().fade(), + ), + ], ), - if (_c.isOpenSidebar.value) - Expanded( - child: VideoPlayerSidebar( - controller: _c, - ), - ) - ], - ), + ), + if (_c.isOpenSidebar.value) + Expanded( + child: VideoPlayerSidebar( + controller: _c, + ), + ) + ], ); }); } diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index b2ce4893..9461e895 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -76,6 +76,40 @@ class _VideoPlayerDesktopControlsState }, child: Stack( children: [ + // subtitle + Positioned.fill( + child: Obx( + () { + final textStyle = TextStyle( + height: 1.4, + fontSize: _c.subtitleFontSize.value, + letterSpacing: 0.0, + wordSpacing: 0.0, + color: _c.subtitleFontColor.value, + fontWeight: _c.subtitleFontWeight.value, + backgroundColor: _c.subtitleBackgroundColor.value, + ); + _subtitleViewKey.currentState?.textAlign = + _c.subtitleTextAlign.value; + _subtitleViewKey.currentState?.style = textStyle; + _subtitleViewKey.currentState?.padding = + EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + _showControls ? 100.0 : 16.0, + ); + return SubtitleView( + controller: _c.videoController, + configuration: SubtitleViewConfiguration( + style: textStyle, + textAlign: _c.subtitleTextAlign.value, + ), + key: _subtitleViewKey, + ); + }, + ), + ), Positioned.fill( child: SizedBox.expand( child: Center( @@ -187,41 +221,6 @@ class _VideoPlayerDesktopControlsState ), ), ), - // subtitle - Positioned.fill( - child: // subtitle - Obx( - () { - final textStyle = TextStyle( - height: 1.4, - fontSize: _c.subtitleFontSize.value, - letterSpacing: 0.0, - wordSpacing: 0.0, - color: _c.subtitleFontColor.value, - fontWeight: _c.subtitleFontWeight.value, - backgroundColor: _c.subtitleBackgroundColor.value, - ); - _subtitleViewKey.currentState?.textAlign = - _c.subtitleTextAlign.value; - _subtitleViewKey.currentState?.style = textStyle; - _subtitleViewKey.currentState?.padding = - EdgeInsets.fromLTRB( - 16.0, - 0.0, - 16.0, - _showControls ? 100.0 : 16.0, - ); - return SubtitleView( - controller: _c.videoController, - configuration: SubtitleViewConfiguration( - style: textStyle, - textAlign: _c.subtitleTextAlign.value, - ), - key: _subtitleViewKey, - ); - }, - ), - ), Positioned.fill( child: Column( children: [ @@ -355,7 +354,7 @@ class _Footer extends StatelessWidget { ), const SizedBox(width: 20), Expanded( - child: _Progress(controller: controller), + child: _SeekBar(controller: controller), ), const SizedBox(width: 20), // 总时长 @@ -1065,17 +1064,17 @@ class _SpeedState extends State<_Speed> { } } -class _Progress extends StatefulWidget { - const _Progress({ +class _SeekBar extends StatefulWidget { + const _SeekBar({ required this.controller, }); final VideoPlayerController controller; @override - State<_Progress> createState() => _ProgressState(); + State<_SeekBar> createState() => _SeekBarState(); } -class _ProgressState extends State<_Progress> { +class _SeekBarState extends State<_SeekBar> { Duration position = const Duration(); Duration duration = const Duration(); bool _isDrag = false; diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 9b6c6c72..7c67503e 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -35,7 +35,7 @@ class _VideoPlayerMobileControlsState extends State { data: ThemeData.dark(useMaterial3: true), child: Stack( children: [ - const SizedBox.expand(), + // 字幕 Positioned.fill( child: Obx( () { @@ -68,6 +68,7 @@ class _VideoPlayerMobileControlsState extends State { }, ), ), + // 手势层 Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -104,6 +105,7 @@ class _VideoPlayerMobileControlsState extends State { child: const SizedBox.expand(), ), ), + // 中间显示 Positioned.fill( child: Center( child: Obx(() { @@ -214,6 +216,7 @@ class _VideoPlayerMobileControlsState extends State { }), ), ), + // 头部控制栏 Positioned( top: 0, left: 0, @@ -225,6 +228,7 @@ class _VideoPlayerMobileControlsState extends State { ), ), ), + // 底部控制栏 Positioned( bottom: 0, left: 0, @@ -234,6 +238,23 @@ class _VideoPlayerMobileControlsState extends State { child: _Footer(controller: _c), ), ), + Positioned.fill( + child: Obx( + () { + if (!_c.showSidebar.value) { + return const SizedBox.shrink(); + } + return GestureDetector( + child: Container( + color: Colors.black54, + ), + onTap: () { + _c.showSidebar.value = false; + }, + ); + }, + ), + ) ], ), ), @@ -434,7 +455,19 @@ class _Footer extends StatelessWidget { ), ), ), + // torrent files const SizedBox(width: 10), + Obx(() { + if (controller.torrentMediaFileList.isEmpty) { + return const SizedBox.shrink(); + } + return IconButton( + onPressed: () { + controller.toggleSideBar(SidebarTab.torrentFiles); + }, + icon: const Icon(Icons.video_file), + ); + }), IconButton( onPressed: () { controller.toggleSideBar(SidebarTab.tracks); @@ -513,47 +546,50 @@ class _SeekBarState extends State<_SeekBar> { @override Widget build(BuildContext context) { - return SliderTheme( - data: const SliderThemeData( - trackHeight: 2, - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: RoundSliderOverlayShape( - overlayRadius: 12, - ), - ), - child: Slider( - min: 0, - max: _duration.inMilliseconds.toDouble(), - value: clampDouble( - _position.inMilliseconds.toDouble(), - 0, - _duration.inMilliseconds.toDouble(), - ), - secondaryTrackValue: clampDouble( - _buffer.inMilliseconds.toDouble(), - 0, - _duration.inMilliseconds.toDouble(), + return SizedBox( + height: 13, + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 2, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: 12, + ), ), - onChanged: (value) { - if (_isSliderDraging) { - setState(() { - _position = Duration(milliseconds: value.toInt()); - }); - } - }, - onChangeStart: (value) { - _isSliderDraging = true; - }, - onChangeEnd: (value) { - if (_isSliderDraging) { - widget.controller.player.seek( - Duration(milliseconds: value.toInt()), - ); - _isSliderDraging = false; - } - }, - )); + child: Slider( + min: 0, + max: _duration.inMilliseconds.toDouble(), + value: clampDouble( + _position.inMilliseconds.toDouble(), + 0, + _duration.inMilliseconds.toDouble(), + ), + secondaryTrackValue: clampDouble( + _buffer.inMilliseconds.toDouble(), + 0, + _duration.inMilliseconds.toDouble(), + ), + onChanged: (value) { + if (_isSliderDraging) { + setState(() { + _position = Duration(milliseconds: value.toInt()); + }); + } + }, + onChangeStart: (value) { + _isSliderDraging = true; + }, + onChangeEnd: (value) { + if (_isSliderDraging) { + widget.controller.player.seek( + Duration(milliseconds: value.toInt()), + ); + _isSliderDraging = false; + } + }, + )), + ); } } diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index d7ee622e..f8a00229 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -3,6 +3,7 @@ import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:get/get.dart'; import 'package:media_kit/media_kit.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; +import 'package:miru_app/views/widgets/list_title.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; @@ -49,6 +50,7 @@ class _VideoPlayerSidebarState extends State { child: Column( children: [ TabBar( + tabAlignment: TabAlignment.center, isScrollable: true, tabs: _tabs.keys.map((e) => Tab(text: e.name)).toList(), ), @@ -97,6 +99,16 @@ class _VideoPlayerSidebarState extends State { @override Widget build(BuildContext context) { + if (_c.torrentMediaFileList.isNotEmpty) { + _tabs.addAll( + { + SidebarTab.torrentFiles: _TorrentFiles( + controller: _c, + ), + }, + ); + } + if (_c.qualityMap.isNotEmpty) { _tabs.addAll( { @@ -597,13 +609,9 @@ class _TrackSelector extends StatelessWidget { return ListView( padding: const EdgeInsets.symmetric(vertical: 10), children: [ - const Padding( - padding: EdgeInsets.only(left: 16), - child: Text( - "Subtitle", - ), + const ListTitle( + title: "Subtitle", ), - const SizedBox(height: 5), ListTile( selected: SubtitleTrack.no() == controller.player.state.track.subtitle, @@ -651,11 +659,8 @@ class _TrackSelector extends StatelessWidget { }, ), const SizedBox(height: 10), - const Padding( - padding: EdgeInsets.only(left: 16), - child: Text( - "Audio", - ), + const ListTitle( + title: "Audio", ), const SizedBox(height: 5), for (final audio in controller.player.state.tracks.audio) @@ -675,3 +680,30 @@ class _TrackSelector extends StatelessWidget { ); } } + +class _TorrentFiles extends StatelessWidget { + const _TorrentFiles({required this.controller}); + final VideoPlayerController controller; + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + for (final file in controller.torrentMediaFileList) + ListTile( + selected: controller.currentTorrentFile.value == file, + title: Text( + file, + style: const TextStyle( + fontSize: 13, + ), + ), + onTap: () { + controller.playTorrentFile(file); + controller.showSidebar.value = false; + }, + ), + ], + ); + } +} From b45a3d007e11d54219c29e2122470ba89506c617 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Fri, 2 Feb 2024 14:48:06 +0800 Subject: [PATCH 06/19] Remove minimumSize property and add always on top functionality --- lib/main.dart | 1 - .../video/video_player_desktop_controls.dart | 46 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 210d7146..abe63936 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,7 +61,6 @@ void main(List args) async { WindowOptions windowOptions = WindowOptions( size: size, center: true, - minimumSize: const Size(600, 500), skipTaskbar: false, titleBarStyle: TitleBarStyle.hidden, ); diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index 9461e895..99d9b4fe 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -256,7 +256,7 @@ class _VideoPlayerDesktopControlsState } } -class _Header extends StatelessWidget { +class _Header extends StatefulWidget { const _Header({ required this.title, required this.episode, @@ -266,6 +266,29 @@ class _Header extends StatelessWidget { final String episode; final VoidCallback onClose; + @override + State<_Header> createState() => _HeaderState(); +} + +class _HeaderState extends State<_Header> { + bool _isAlwaysOnTop = false; + + @override + initState() { + super.initState(); + WindowManager.instance.isAlwaysOnTop().then((value) { + _isAlwaysOnTop = value; + }); + } + + @override + void dispose() { + if (_isAlwaysOnTop) { + WindowManager.instance.setAlwaysOnTop(false); + } + super.dispose(); + } + @override Widget build(BuildContext context) { return Container( @@ -280,14 +303,14 @@ class _Header extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, + widget.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), Text( - episode, + widget.episode, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, @@ -297,6 +320,21 @@ class _Header extends StatelessWidget { ), ), ), + // 置顶 + IconButton( + icon: Icon( + _isAlwaysOnTop ? FluentIcons.pinned : FluentIcons.pin, + ), + onPressed: () async { + WindowManager.instance.setAlwaysOnTop( + !_isAlwaysOnTop, + ); + setState(() { + _isAlwaysOnTop = !_isAlwaysOnTop; + }); + }, + ), + const SizedBox(width: 10), IconButton( icon: const Icon( FluentIcons.chrome_minimize, @@ -307,7 +345,7 @@ class _Header extends StatelessWidget { ), const SizedBox(width: 10), IconButton( - onPressed: onClose, + onPressed: widget.onClose, icon: const Icon( FluentIcons.chevron_down, ), From 12d282cefdabc22836f5a83ffff1196ec0771fd5 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Fri, 2 Feb 2024 15:25:33 +0800 Subject: [PATCH 07/19] Update color and background settings --- lib/controllers/watch/video_controller.dart | 3 +- lib/utils/color.dart | 12 + lib/views/pages/watch/video/video_player.dart | 5 +- .../video/video_player_desktop_controls.dart | 5 +- .../video/video_player_mobile_controls.dart | 5 +- .../watch/video/video_player_sidebar.dart | 263 +++++++++++++----- 6 files changed, 226 insertions(+), 67 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index cf917673..4d6eafd4 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -152,7 +152,8 @@ class VideoPlayerController extends GetxController { final subtitleFontWeight = FontWeight.normal.obs; final subtitleTextAlign = TextAlign.center.obs; final subtitleFontColor = Colors.white.obs; - final subtitleBackgroundColor = const Color(0xaa000000).obs; + final subtitleBackgroundColor = Colors.black.obs; + final subtitleBackgroundOpacity = 0.7.obs; // 侧边栏初始化 tab final initSidebarTab = SidebarTab.episodes.obs; diff --git a/lib/utils/color.dart b/lib/utils/color.dart index faaf6d8f..43c84da3 100644 --- a/lib/utils/color.dart +++ b/lib/utils/color.dart @@ -19,4 +19,16 @@ class ColorUtils { ][colorIndex]; return color!; } + + static List baseColors = [ + Colors.red, + Colors.orange, + Colors.yellow, + Colors.green, + Colors.cyan, + Colors.blue, + Colors.purple, + Colors.white, + Colors.black, + ]; } diff --git a/lib/views/pages/watch/video/video_player.dart b/lib/views/pages/watch/video/video_player.dart index 08a790a4..a86df079 100644 --- a/lib/views/pages/watch/video/video_player.dart +++ b/lib/views/pages/watch/video/video_player.dart @@ -117,7 +117,10 @@ class _VideoPlayerState extends State { @override Widget build(BuildContext context) { return PlatformBuildWidget( - androidBuilder: (context) => Scaffold(body: _buildContent()), + androidBuilder: (context) => Theme( + data: ThemeData.dark(useMaterial3: true), + child: Scaffold(body: _buildContent()), + ), desktopBuilder: ((context) => _buildContent()), ); } diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index 99d9b4fe..e39358c8 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -87,7 +87,10 @@ class _VideoPlayerDesktopControlsState wordSpacing: 0.0, color: _c.subtitleFontColor.value, fontWeight: _c.subtitleFontWeight.value, - backgroundColor: _c.subtitleBackgroundColor.value, + backgroundColor: + _c.subtitleBackgroundColor.value.withOpacity( + _c.subtitleBackgroundOpacity.value, + ), ); _subtitleViewKey.currentState?.textAlign = _c.subtitleTextAlign.value; diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 7c67503e..06058fd3 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -46,7 +46,10 @@ class _VideoPlayerMobileControlsState extends State { wordSpacing: 0.0, color: _c.subtitleFontColor.value, fontWeight: _c.subtitleFontWeight.value, - backgroundColor: _c.subtitleBackgroundColor.value, + backgroundColor: + _c.subtitleBackgroundColor.value.withOpacity( + _c.subtitleBackgroundOpacity.value, + ), ); _subtitleViewKey.currentState?.textAlign = _c.subtitleTextAlign.value; diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index f8a00229..e0236234 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -3,6 +3,7 @@ import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:get/get.dart'; import 'package:media_kit/media_kit.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; +import 'package:miru_app/utils/color.dart'; import 'package:miru_app/views/widgets/list_title.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; @@ -42,24 +43,27 @@ class _VideoPlayerSidebarState extends State { }; Widget _buildAndroid(BuildContext context) { - return DefaultTabController( - length: _tabs.length, - initialIndex: !_tabs.keys.toList().contains(_c.initSidebarTab.value) - ? 0 - : _tabs.keys.toList().indexOf(_c.initSidebarTab.value), - child: Column( - children: [ - TabBar( - tabAlignment: TabAlignment.center, - isScrollable: true, - tabs: _tabs.keys.map((e) => Tab(text: e.name)).toList(), - ), - Expanded( - child: TabBarView( - children: _tabs.values.toList(), + return Container( + color: ThemeData.dark().colorScheme.background, + child: DefaultTabController( + length: _tabs.length, + initialIndex: !_tabs.keys.toList().contains(_c.initSidebarTab.value) + ? 0 + : _tabs.keys.toList().indexOf(_c.initSidebarTab.value), + child: Column( + children: [ + TabBar( + tabAlignment: TabAlignment.center, + isScrollable: true, + tabs: _tabs.keys.map((e) => Tab(text: e.name)).toList(), ), - ), - ], + Expanded( + child: TabBarView( + children: _tabs.values.toList(), + ), + ), + ], + ), ), ); } @@ -155,6 +159,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { children: [ fluent.Card( child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Subtitle'), @@ -196,25 +201,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { runSpacing: 10.0, spacing: 8.0, children: [ - fluent.Button( - autofocus: - _c.subtitleFontColor.value == Colors.white, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleFontColor.value = Colors.white; - Navigator.of(context).pop(Colors.white); - }, - child: Container( - height: 32, - width: 32, - color: Colors.white, - ), - ), - ...fluent.Colors.accentColors.map((color) { + ...ColorUtils.baseColors.map((color) { return fluent.Button( autofocus: _c.subtitleFontColor.value == color, style: fluent.ButtonStyle( @@ -266,26 +253,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { runSpacing: 10.0, spacing: 8.0, children: [ - fluent.Button( - autofocus: _c.subtitleBackgroundColor.value == - Colors.transparent, - style: fluent.ButtonStyle( - padding: fluent.ButtonState.all( - const EdgeInsets.all(4.0), - ), - ), - onPressed: () { - _c.subtitleBackgroundColor.value = - Colors.transparent; - Navigator.of(context).pop(Colors.transparent); - }, - child: Container( - height: 32, - width: 32, - color: Colors.transparent, - ), - ), - ...fluent.Colors.accentColors.map((color) { + ...ColorUtils.baseColors.map((color) { return fluent.Button( autofocus: _c.subtitleBackgroundColor.value == color, @@ -333,11 +301,9 @@ class _SideBarSettingsState extends State<_SideBarSettings> { Expanded( child: Obx( () => fluent.Slider( - value: _c.subtitleBackgroundColor.value.opacity, + value: _c.subtitleBackgroundOpacity.value, onChanged: (value) { - _c.subtitleBackgroundColor.value = _c - .subtitleBackgroundColor.value - .withOpacity(value); + _c.subtitleBackgroundOpacity.value = value; }, min: 0, max: 1, @@ -347,8 +313,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { const SizedBox(width: 10), Obx( () => Text( - _c.subtitleBackgroundColor.value.opacity - .toStringAsFixed(2), + _c.subtitleBackgroundOpacity.value.toStringAsFixed(2), ), ), ], @@ -510,6 +475,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { const SizedBox(height: 20), fluent.Card( child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("Play mode"), @@ -554,7 +520,178 @@ class _SideBarSettingsState extends State<_SideBarSettings> { } Widget _buildAndroid(BuildContext context) { - return Container(); + return ListView( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + children: [ + Text( + 'Subtitle', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + Row( + children: [ + const Text('Font size'), + Expanded( + child: Obx( + () => Slider( + value: _c.subtitleFontSize.value, + onChanged: (value) { + _c.subtitleFontSize.value = value; + }, + min: 20, + max: 80, + ), + ), + ), + Obx( + () => Text( + _c.subtitleFontSize.value.toStringAsFixed(0), + ), + ), + ], + ), + Row( + children: [ + const Text('Font color'), + const SizedBox(width: 10), + Obx( + () => DropdownButton( + value: _c.subtitleFontColor.value, + onChanged: (value) { + if (value != null) _c.subtitleFontColor.value = value; + }, + items: ColorUtils.baseColors + .map( + (color) => DropdownMenuItem( + value: color, + child: Container( + height: 32, + width: 32, + color: color, + ), + ), + ) + .toList(), + ), + ), + ], + ), + Row( + children: [ + const Text('Background color'), + const SizedBox(width: 10), + Obx( + () => DropdownButton( + value: _c.subtitleBackgroundColor.value, + onChanged: (value) { + if (value != null) _c.subtitleBackgroundColor.value = value; + }, + items: ColorUtils.baseColors + .map( + (color) => DropdownMenuItem( + value: color, + child: Container( + height: 32, + width: 32, + color: color, + ), + ), + ) + .toList(), + ), + ), + ], + ), + Row( + children: [ + const Text('Background opacity'), + Expanded( + child: Obx( + () => Slider( + value: _c.subtitleBackgroundOpacity.value, + onChanged: (value) { + _c.subtitleBackgroundOpacity.value = value; + }, + min: 0, + max: 1, + ), + ), + ), + Obx( + () => Text( + _c.subtitleBackgroundOpacity.value.toStringAsFixed(2), + ), + ), + ], + ), + const SizedBox(height: 10), + // textAlign + Row( + children: [ + const Text('Text align'), + const SizedBox(width: 10), + Obx( + () => DropdownButton( + value: _c.subtitleTextAlign.value, + onChanged: (value) { + if (value != null) _c.subtitleTextAlign.value = value; + }, + items: const [ + DropdownMenuItem( + value: TextAlign.justify, + child: Icon(Icons.format_align_justify), + ), + DropdownMenuItem( + value: TextAlign.left, + child: Icon(Icons.format_align_left), + ), + DropdownMenuItem( + value: TextAlign.right, + child: Icon(Icons.format_align_right), + ), + DropdownMenuItem( + value: TextAlign.center, + child: Icon(Icons.format_align_center), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + const Text("Font weight"), + const SizedBox(height: 10), + Obx( + () => SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: FontWeight.normal, label: Text("Normal")), + ButtonSegment(value: FontWeight.bold, label: Text("Bold")), + ], + selected: {_c.subtitleFontWeight.value}, + onSelectionChanged: (value) { + _c.subtitleFontWeight.value = value.first; + }, + ), + ), + const SizedBox(height: 20), + const Text("Play mode"), + const SizedBox(height: 10), + Obx( + () => SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: PlaylistMode.loop, label: Text("Loop")), + ButtonSegment(value: PlaylistMode.single, label: Text("Single")), + ButtonSegment(value: PlaylistMode.none, label: Text("Auto next")), + ], + selected: {_c.playMode.value}, + onSelectionChanged: (value) { + _c.playMode.value = value.first; + }, + ), + ), + ], + ); } @override From 08f1536f689948300e3cc4492b951d527daf3585 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 3 Feb 2024 15:49:03 +0800 Subject: [PATCH 08/19] Fix play pause button mismatch --- .../video/video_player_desktop_controls.dart | 3 ++- .../video/video_player_mobile_controls.dart | 27 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index e39358c8..d5b46f8c 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -469,7 +469,8 @@ class _Footer extends StatelessWidget { StreamBuilder( stream: controller.player.stream.playing, builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!) { + if (snapshot.hasData && snapshot.data! || + controller.player.state.playing) { return IconButton( onPressed: controller.player.pause, icon: const Icon( diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 06058fd3..b1a4d168 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -220,27 +220,23 @@ class _VideoPlayerMobileControlsState extends State { ), ), // 头部控制栏 - Positioned( - top: 0, - left: 0, - right: 0, - child: Opacity( - opacity: _showControls ? 1.0 : 0.0, + if (_showControls) + Positioned( + top: 0, + left: 0, + right: 0, child: _Header( controller: _c, ), ), - ), // 底部控制栏 - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Opacity( - opacity: _showControls ? 1.0 : 0.0, + if (_showControls) + Positioned( + bottom: 0, + left: 0, + right: 0, child: _Footer(controller: _c), ), - ), Positioned.fill( child: Obx( () { @@ -368,7 +364,8 @@ class _Footer extends StatelessWidget { StreamBuilder( stream: controller.player.stream.playing, builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!) { + if (snapshot.hasData && snapshot.data! || + controller.player.state.playing) { return IconButton( onPressed: controller.player.pause, icon: const Icon( From ed9c93a18f19574bf0e1717469ee50ee6745d5ae Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 3 Feb 2024 20:10:26 +0800 Subject: [PATCH 09/19] Add volume control, screen brightness, auto orientation --- lib/controllers/watch/video_controller.dart | 5 +- .../video/video_player_mobile_controls.dart | 244 +++++++++++++++--- pubspec.lock | 20 +- pubspec.yaml | 4 + 4 files changed, 236 insertions(+), 37 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 4d6eafd4..0f30afe2 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:auto_orientation/auto_orientation.dart'; import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -166,8 +167,10 @@ class VideoPlayerController extends GetxController { if (Platform.isAndroid) { // 切换到横屏 SystemChrome.setPreferredOrientations( - [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight], + ); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await AutoOrientation.landscapeAutoMode(forceSensor: true); } // 切换剧集 diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index b1a4d168..e84361c5 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -6,10 +6,13 @@ import 'package:get/get.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; +import 'package:miru_app/utils/layout.dart'; import 'package:miru_app/utils/router.dart'; import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/progress.dart'; +import 'package:screen_brightness/screen_brightness.dart'; +import 'package:volume_controller/volume_controller.dart'; class VideoPlayerMobileControls extends StatefulWidget { const VideoPlayerMobileControls({super.key, required this.controller}); @@ -24,6 +27,51 @@ class _VideoPlayerMobileControlsState extends State { late final VideoPlayerController _c = widget.controller; final _subtitleViewKey = GlobalKey(); bool _showControls = true; + double _currentBrightness = 0; + double _currentVolume = 0; + // 是否是调整亮度 + bool _isBrightness = false; + // 是否正在调节 + bool _isAdjusting = false; + // 滑动时的进度 + Duration _position = Duration.zero; + // 是否左右滑动调整进度 + bool _isSeeking = false; + // 是否长按加速 + bool _isLongPress = false; + // 定时器 + Timer? _timer; + + _updateTimer() { + _timer?.cancel(); + _timer = null; + setState(() { + _showControls = true; + }); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) { + if (mounted) { + setState(() { + _showControls = false; + }); + } + }, + ); + } + + @override + void initState() { + _init(); + super.initState(); + } + + _init() async { + _updateTimer(); + VolumeController().showSystemUI = false; + _currentBrightness = await ScreenBrightness().current; + _currentVolume = await VolumeController().getVolume(); + } @override Widget build(BuildContext context) { @@ -71,39 +119,158 @@ class _VideoPlayerMobileControlsState extends State { }, ), ), + // 顶部提示 + Positioned( + top: 30, + left: 0, + right: 0, + child: Center( + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(5)), + color: Colors.black45, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isSeeking) + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_position.inMinutes}:${(_position.inSeconds % 60).toString().padLeft(2, '0')}', + ), + const Text('/'), + Text( + '${_c.player.state.duration.inMinutes}:${(_c.player.state.duration.inSeconds % 60).toString().padLeft(2, '0')}', + ), + ], + ), + ), + if (_isLongPress) + const Padding( + padding: EdgeInsets.all(8.0), + child: Text('Playing at 3x speed'), + ), + if (_isAdjusting) + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isBrightness) ...[ + const Icon(Icons.brightness_5), + const SizedBox(width: 5), + Text( + (_currentBrightness * 100).toStringAsFixed(0), + ) + ], + if (!_isBrightness) ...[ + const Icon(Icons.volume_up), + const SizedBox(width: 5), + Text( + (_currentVolume * 100).toStringAsFixed(0), + ) + ], + ], + ), + ), + ], + ), + ), + ), + ), // 手势层 Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { - setState(() { - _showControls = !_showControls; - }); - }, - onDoubleTap: () { - if (_c.player.state.playing) { - _c.player.pause(); - } else { - _c.player.play(); + if (_showControls) { + _showControls = false; + setState(() {}); + return; } + _updateTimer(); }, - // 左右滑动 - onHorizontalDragUpdate: (details) { - if (details.delta.dx > 0) { + onDoubleTapDown: (details) { + // 如果左边点击快退,中间暂停,右边快进 + final dx = details.localPosition.dx; + final width = LayoutUtils.width / 3; + if (dx < width) { _c.player.seek( - _c.player.state.position + const Duration(seconds: 1), + _c.player.state.position - const Duration(seconds: 10), ); - } else { + } else if (dx > width * 2) { _c.player.seek( - _c.player.state.position - const Duration(seconds: 1), + _c.player.state.position + const Duration(seconds: 10), ); + } else { + if (_c.player.state.playing) { + _c.player.pause(); + } else { + _c.player.play(); + } } }, - onLongPress: () { + onVerticalDragStart: (details) { + _isBrightness = + details.localPosition.dx < LayoutUtils.width / 2; + }, + // 左右两边上下滑动 + onVerticalDragUpdate: (details) { + final add = details.delta.dy / 500; + // 如果是左边调节亮度 + if (_isBrightness) { + _currentBrightness = (_currentBrightness - add).clamp(0, 1); + ScreenBrightness().setScreenBrightness(_currentBrightness); + } + // 如果是右边调节音量 + else { + _currentVolume = (_currentVolume - add).clamp(0, 1); + VolumeController().setVolume(_currentVolume); + } + _isAdjusting = true; + setState(() {}); + }, + onHorizontalDragStart: (details) { + _position = _c.player.state.position; + }, + onVerticalDragEnd: (details) { + _isAdjusting = false; + setState(() {}); + }, + // 左右滑动 + onHorizontalDragUpdate: (details) { + double scale = 200000 / LayoutUtils.width; + Duration pos = _position + + Duration( + milliseconds: (details.delta.dx * scale).round(), + ); + _position = Duration( + milliseconds: pos.inMilliseconds.clamp( + 0, + _c.player.state.duration.inMilliseconds, + ), + ); + _isSeeking = true; + setState(() {}); + }, + onHorizontalDragEnd: (details) { + _c.player.seek(_position); + _isSeeking = false; + setState(() {}); + }, + onLongPressStart: (details) { + _isLongPress = true; _c.player.setRate(3.0); + setState(() {}); }, onLongPressEnd: (details) { _c.player.setRate(_c.currentSpeed.value); + _isLongPress = false; + setState(() {}); }, child: const SizedBox.expand(), ), @@ -399,34 +566,39 @@ class _Footer extends StatelessWidget { StreamBuilder( stream: controller.player.stream.position, builder: (context, snapshot) { + late Duration position; if (snapshot.hasData) { - final position = snapshot.data as Duration; - return Text( - '${position.inMinutes}:${position.inSeconds % 60}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - ), - ); + position = snapshot.data as Duration; + } else { + position = controller.player.state.position; } - return const SizedBox.shrink(); + + return Text( + '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); }, ), const Text('/'), StreamBuilder( stream: controller.player.stream.duration, builder: (context, snapshot) { + late Duration duration; if (snapshot.hasData) { - final duration = snapshot.data as Duration; - return Text( - '${duration.inMinutes}:${duration.inSeconds % 60}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - ), - ); + duration = snapshot.data as Duration; + } else { + duration = controller.player.state.duration; } - return const SizedBox.shrink(); + return Text( + '${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); }, ), const Spacer(), @@ -514,6 +686,10 @@ class _SeekBarState extends State<_SeekBar> { @override void initState() { super.initState(); + _duration = widget.controller.player.state.duration; + _position = widget.controller.player.state.position; + _buffer = widget.controller.player.state.buffer; + _durationSubscription = widget.controller.player.stream.duration.listen((event) { setState(() { diff --git a/pubspec.lock b/pubspec.lock index ad2e560a..5cc0d294 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + auto_orientation: + dependency: "direct main" + description: + name: auto_orientation + sha256: cd56bb59b36fa54cc28ee254bc600524f022a4862f31d5ab20abd7bb1c54e678 + url: "https://pub.dev" + source: hosted + version: "2.3.1" autotrie: dependency: transitive description: @@ -289,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + dlna_dart: + dependency: "direct main" + description: + name: dlna_dart + sha256: ae07c1c53077bbf58756fa589f936968719b0085441981d33e74f82f89d1d281 + url: "https://pub.dev" + source: hosted + version: "0.0.8" easy_refresh: dependency: "direct main" description: @@ -1071,7 +1087,7 @@ packages: source: hosted version: "1.0.2" screen_brightness: - dependency: transitive + dependency: "direct main" description: name: screen_brightness sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd @@ -1436,7 +1452,7 @@ packages: source: hosted version: "2.1.4" volume_controller: - dependency: transitive + dependency: "direct main" description: name: volume_controller sha256: "189bdc7a554f476b412e4c8b2f474562b09d74bc458c23667356bce3ca1d48c9" diff --git a/pubspec.yaml b/pubspec.yaml index 914fe46b..868f0e6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,10 @@ dependencies: flutter_socks_proxy: ^0.0.3 logging: ^1.2.0 share_plus: ^7.2.1 + volume_controller: ^2.0.7 + screen_brightness: ^0.2.2+1 + auto_orientation: ^2.3.1 + dlna_dart: ^0.0.8 dev_dependencies: flutter_test: From 6bc492a1d2318c88e4175d017d3a7a2fefa02b35 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 3 Feb 2024 20:37:37 +0800 Subject: [PATCH 10/19] Fix content-type check and dispose timer and focus node --- lib/controllers/watch/video_controller.dart | 7 +++++-- .../video/video_player_desktop_controls.dart | 7 +++++++ .../video/video_player_mobile_controls.dart | 16 +++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 0f30afe2..a8ff3497 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -383,8 +383,11 @@ class VideoPlayerController extends GetxController { ); // 请求判断 content-type 是否为 m3u8 - final contentType = response.headers.value('content-type'); - if (contentType == null || !contentType.contains('mpegurl')) { + final contentType = response.headers.value('content-type')?.toLowerCase(); + if (contentType == null || + !contentType.contains('mpegurl') && + !contentType.contains('m3u8') && + !contentType.contains('mp2t')) { logger.info('not m3u8'); return; } diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index d5b46f8c..0cf8470f 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -56,6 +56,13 @@ class _VideoPlayerDesktopControlsState _updateTimer(); } + @override + void dispose() { + _timer?.cancel(); + _focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MouseRegion( diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index e84361c5..6e5d2139 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -60,17 +60,23 @@ class _VideoPlayerMobileControlsState extends State { ); } + _init() async { + _updateTimer(); + VolumeController().showSystemUI = false; + _currentBrightness = await ScreenBrightness().current; + _currentVolume = await VolumeController().getVolume(); + } + @override void initState() { _init(); super.initState(); } - _init() async { - _updateTimer(); - VolumeController().showSystemUI = false; - _currentBrightness = await ScreenBrightness().current; - _currentVolume = await VolumeController().getVolume(); + @override + void dispose() { + _timer?.cancel(); + super.dispose(); } @override From e0b725f0ba9da8ce5f1d39b8765a304970a79c28 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Mon, 5 Feb 2024 23:00:57 +0800 Subject: [PATCH 11/19] Add color and padding to mobile controls, fix time formatting in desktop controls --- lib/utils/color.dart | 4 +- .../video/video_player_desktop_controls.dart | 7 +- .../video/video_player_mobile_controls.dart | 3 +- .../watch/video/video_player_sidebar.dart | 226 +++++++++++------- 4 files changed, 146 insertions(+), 94 deletions(-) diff --git a/lib/utils/color.dart b/lib/utils/color.dart index 43c84da3..c562ae42 100644 --- a/lib/utils/color.dart +++ b/lib/utils/color.dart @@ -21,6 +21,8 @@ class ColorUtils { } static List baseColors = [ + Colors.white, + Colors.black, Colors.red, Colors.orange, Colors.yellow, @@ -28,7 +30,5 @@ class ColorUtils { Colors.cyan, Colors.blue, Colors.purple, - Colors.white, - Colors.black, ]; } diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index 0cf8470f..a3058fe2 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -390,7 +390,7 @@ class _Footer extends StatelessWidget { if (snapshot.hasData) { final position = snapshot.data as Duration; return Text( - '${position.inMinutes}:${position.inSeconds % 60}', + '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, @@ -412,7 +412,7 @@ class _Footer extends StatelessWidget { if (snapshot.hasData) { final duration = snapshot.data as Duration; return Text( - '${duration.inMinutes}:${duration.inSeconds % 60}', + '${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, @@ -1159,7 +1159,8 @@ class _SeekBarState extends State<_SeekBar> { max: duration.inSeconds < position.inSeconds ? position.inSeconds.toDouble() : duration.inSeconds.toDouble(), - label: '${position.inMinutes}:${position.inSeconds % 60}', + label: + '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', onChanged: (value) { _isDrag = true; setState(() { diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 6e5d2139..0cb3739a 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -346,9 +346,10 @@ class _VideoPlayerMobileControlsState extends State { ); } return Card( + color: Theme.of(context).colorScheme.surfaceVariant, elevation: 0, child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(10), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index e0236234..0c4595d4 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -527,18 +527,25 @@ class _SideBarSettingsState extends State<_SideBarSettings> { 'Subtitle', style: TextStyle(color: Theme.of(context).colorScheme.primary), ), + const SizedBox(height: 20), + const Text('Font size'), + const SizedBox(height: 10), Row( children: [ - const Text('Font size'), Expanded( child: Obx( - () => Slider( - value: _c.subtitleFontSize.value, - onChanged: (value) { - _c.subtitleFontSize.value = value; - }, - min: 20, - max: 80, + () => SliderTheme( + data: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + ), + child: Slider( + value: _c.subtitleFontSize.value, + onChanged: (value) { + _c.subtitleFontSize.value = value; + }, + min: 20, + max: 80, + ), ), ), ), @@ -549,70 +556,102 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), ], ), - Row( - children: [ - const Text('Font color'), - const SizedBox(width: 10), - Obx( - () => DropdownButton( - value: _c.subtitleFontColor.value, - onChanged: (value) { - if (value != null) _c.subtitleFontColor.value = value; - }, - items: ColorUtils.baseColors - .map( - (color) => DropdownMenuItem( - value: color, - child: Container( - height: 32, - width: 32, + const Text('Font color'), + const SizedBox(height: 10), + Obx( + () { + final selectColor = _c.subtitleFontColor.value; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final color in ColorUtils.baseColors) ...[ + GestureDetector( + onTap: () { + _c.subtitleFontColor.value = color; + }, + child: Container( + decoration: BoxDecoration( + border: selectColor == color + ? Border.all( + color: Colors.grey, + width: 2, + ) + : null, color: color, + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), ), + height: 32, + width: 32, ), - ) - .toList(), + ), + const SizedBox(width: 10) + ], + ], ), - ), - ], + ); + }, ), - Row( - children: [ - const Text('Background color'), - const SizedBox(width: 10), - Obx( - () => DropdownButton( - value: _c.subtitleBackgroundColor.value, - onChanged: (value) { - if (value != null) _c.subtitleBackgroundColor.value = value; - }, - items: ColorUtils.baseColors - .map( - (color) => DropdownMenuItem( - value: color, - child: Container( - height: 32, - width: 32, + const SizedBox(height: 10), + const Text('Background color'), + const SizedBox(height: 10), + Obx( + () { + final selectColor = _c.subtitleBackgroundColor.value; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final color in ColorUtils.baseColors) ...[ + GestureDetector( + onTap: () { + _c.subtitleBackgroundColor.value = color; + }, + child: Container( + decoration: BoxDecoration( + border: selectColor == color + ? Border.all( + color: Colors.grey, + width: 2, + ) + : null, color: color, + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), ), + height: 32, + width: 32, ), - ) - .toList(), + ), + const SizedBox(width: 10) + ], + ], ), - ), - ], + ); + }, ), + const SizedBox(height: 10), + const Text('Background opacity'), + const SizedBox(height: 10), Row( children: [ - const Text('Background opacity'), Expanded( child: Obx( - () => Slider( - value: _c.subtitleBackgroundOpacity.value, - onChanged: (value) { - _c.subtitleBackgroundOpacity.value = value; - }, - min: 0, - max: 1, + () => SliderTheme( + data: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + ), + child: Slider( + value: _c.subtitleBackgroundOpacity.value, + onChanged: (value) { + _c.subtitleBackgroundOpacity.value = value; + }, + min: 0, + max: 1, + ), ), ), ), @@ -625,37 +664,48 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), const SizedBox(height: 10), // textAlign - Row( - children: [ - const Text('Text align'), - const SizedBox(width: 10), - Obx( - () => DropdownButton( - value: _c.subtitleTextAlign.value, - onChanged: (value) { - if (value != null) _c.subtitleTextAlign.value = value; - }, - items: const [ - DropdownMenuItem( - value: TextAlign.justify, - child: Icon(Icons.format_align_justify), - ), - DropdownMenuItem( - value: TextAlign.left, - child: Icon(Icons.format_align_left), - ), - DropdownMenuItem( - value: TextAlign.right, - child: Icon(Icons.format_align_right), - ), - DropdownMenuItem( - value: TextAlign.center, - child: Icon(Icons.format_align_center), + const Text('Text align'), + const SizedBox(height: 10), + + Obx( + () => Wrap( + children: [ + for (final align in TextAlign.values) ...[ + GestureDetector( + onTap: () { + _c.subtitleTextAlign.value = align; + }, + child: Container( + decoration: BoxDecoration( + border: _c.subtitleTextAlign.value == align + ? Border.all( + color: Colors.grey, + width: 2, + ) + : null, + color: Colors.transparent, + borderRadius: const BorderRadius.all( + Radius.circular(5), + ), + ), + height: 32, + width: 32, + child: Icon( + align == TextAlign.justify + ? Icons.format_align_justify + : align == TextAlign.left + ? Icons.format_align_left + : align == TextAlign.right + ? Icons.format_align_right + : Icons.format_align_center, + color: Colors.white, + ), ), - ], - ), - ), - ], + ), + const SizedBox(width: 10) + ], + ], + ), ), const SizedBox(height: 10), const Text("Font weight"), From 6e3a18cab0bc910617e44c1f0d205e7dcb145a7e Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Mon, 5 Feb 2024 23:55:45 +0800 Subject: [PATCH 12/19] Add remember subtitle settings --- lib/controllers/watch/video_controller.dart | 113 +++++++++++++++++- lib/utils/miru_storage.dart | 64 ++++++---- .../video/video_player_desktop_controls.dart | 6 +- .../watch/video/video_player_sidebar.dart | 7 +- 4 files changed, 154 insertions(+), 36 deletions(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index a8ff3497..6be6a2d3 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -149,12 +149,12 @@ class VideoPlayerController extends GetxController { final isGettingWatchData = true.obs; // 字幕配置 - final subtitleFontSize = 34.0.obs; + final subtitleFontSize = 46.0.obs; final subtitleFontWeight = FontWeight.normal.obs; final subtitleTextAlign = TextAlign.center.obs; final subtitleFontColor = Colors.white.obs; final subtitleBackgroundColor = Colors.black.obs; - final subtitleBackgroundOpacity = 0.7.obs; + final subtitleBackgroundOpacity = 0.5.obs; // 侧边栏初始化 tab final initSidebarTab = SidebarTab.episodes.obs; @@ -172,7 +172,70 @@ class VideoPlayerController extends GetxController { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await AutoOrientation.landscapeAutoMode(forceSensor: true); } + _initSettings(); + _initPlayer(); + play(); + super.onInit(); + } + + _initSettings() { + subtitleFontSize.value = + MiruStorage.getSetting(SettingKey.subtitleFontSize); + subtitleFontColor.value = Color( + MiruStorage.getSetting( + SettingKey.subtitleFontColor, + ), + ); + final fontWeightText = + MiruStorage.getSetting(SettingKey.subtitleFontWeight); + subtitleFontWeight.value = + fontWeightText == 'bold' ? FontWeight.bold : FontWeight.normal; + subtitleBackgroundColor.value = Color(MiruStorage.getSetting( + SettingKey.subtitleBackgroundColor, + )); + subtitleBackgroundOpacity.value = MiruStorage.getSetting( + SettingKey.subtitleBackgroundOpacity, + ); + subtitleTextAlign.value = TextAlign.values[MiruStorage.getSetting( + SettingKey.subtitleTextAlign, + )]; + + ever(subtitleFontSize, (callback) { + MiruStorage.setSetting(SettingKey.subtitleFontSize, callback); + }); + ever(subtitleFontColor, (callback) { + MiruStorage.setSetting( + SettingKey.subtitleFontColor, + callback.value, + ); + }); + ever(subtitleFontWeight, (callback) { + MiruStorage.setSetting( + SettingKey.subtitleFontWeight, + callback == FontWeight.bold ? 'bold' : 'normal', + ); + }); + ever(subtitleBackgroundColor, (callback) { + MiruStorage.setSetting( + SettingKey.subtitleBackgroundColor, + callback.value, + ); + }); + ever(subtitleBackgroundOpacity, (callback) { + MiruStorage.setSetting( + SettingKey.subtitleBackgroundOpacity, + callback, + ); + }); + ever(subtitleTextAlign, (callback) { + MiruStorage.setSetting( + SettingKey.subtitleTextAlign, + callback.index, + ); + }); + } + _initPlayer() { // 切换剧集 ever(index, (callback) { play(); @@ -240,13 +303,41 @@ class VideoPlayerController extends GetxController { } }); + // 监听 track + player.stream.tracks.listen((event) { + if (event.subtitle.isEmpty) { + return; + } + + final latestLanguageSelected = MiruStorage.getSetting( + SettingKey.subtitleLastLanguageSelected, + ); + final latestTitleSelected = MiruStorage.getSetting( + SettingKey.subtitleLastTitleSelected, + ); + if (latestLanguageSelected == null && latestTitleSelected == null) { + return; + } + + final subtitle = event.subtitle.firstWhereOrNull( + (element) { + if (element.id == "no" || element.id == "auto") { + return false; + } + return element.language == latestLanguageSelected || + element.title == latestTitleSelected; + }, + ); + + if (subtitle != null) { + player.setSubtitleTrack(subtitle); + } + }); + // 错误监听 player.stream.error.listen((event) { sendMessage(Message(Text(event))); }); - - play(); - super.onInit(); } // 播放 @@ -470,6 +561,18 @@ class VideoPlayerController extends GetxController { }); } + setSubtitleTrack(SubtitleTrack subtitle) { + player.setSubtitleTrack(subtitle); + MiruStorage.setSetting( + SettingKey.subtitleLastLanguageSelected, + subtitle.language, + ); + MiruStorage.setSetting( + SettingKey.subtitleLastTitleSelected, + subtitle.title, + ); + } + _saveHistory() async { if (player.state.duration.inSeconds == 0) { return; diff --git a/lib/utils/miru_storage.dart b/lib/utils/miru_storage.dart index 3a8540d9..ac014c1d 100644 --- a/lib/utils/miru_storage.dart +++ b/lib/utils/miru_storage.dart @@ -129,6 +129,12 @@ class MiruStorage { await _initSetting(SettingKey.proxy, ''); await _initSetting(SettingKey.proxyType, 'DIRECT'); await _initSetting(SettingKey.saveLog, true); + await _initSetting(SettingKey.subtitleFontSize, 46.0); + await _initSetting(SettingKey.subtitleFontColor, Colors.white.value); + await _initSetting(SettingKey.subtitleFontWeight, 'bold'); + await _initSetting(SettingKey.subtitleBackgroundColor, Colors.black.value); + await _initSetting(SettingKey.subtitleBackgroundOpacity, 0.5); + await _initSetting(SettingKey.subtitleTextAlign, TextAlign.center.index); } static _initSetting(String key, dynamic value) async { @@ -162,29 +168,37 @@ class MiruStorage { } class SettingKey { - static String theme = "Theme"; - static String miruRepoUrl = "MiruRepoUrl"; - static String tmdbKey = 'TMDBKey'; - static String autoCheckUpdate = 'AutoCheckUpdate'; - static String language = 'Language'; - static String novelFontSize = 'NovelFontSize'; - static String enableNSFW = 'EnableNSFW'; - static String videoPlayer = 'VideoPlayer'; - static String databaseVersion = 'DatabaseVersion'; - static String listMode = 'ListMode'; - static String keyI = 'KeyI'; - static String keyJ = 'KeyJ'; - static String arrowLeft = 'Arrowleft'; - static String arrowRight = 'Arrowright'; - static String readingMode = 'ReadingMode'; - static String aniListToken = 'AniListToken'; - static String aniListUserId = 'AniListUserId'; - static String autoTracking = 'AutoTracking'; - static String windowSize = 'WindowsSize'; - static String windowPosition = 'WindowsPosition'; - static String androidWebviewUA = "AndroidWebviewUA"; - static String windowsWebviewUA = "WindowsWebviewUA"; - static String proxy = "Proxy"; - static String proxyType = "ProxyType"; - static String saveLog = "SaveLog"; + static const theme = "Theme"; + static const miruRepoUrl = "MiruRepoUrl"; + static const tmdbKey = 'TMDBKey'; + static const autoCheckUpdate = 'AutoCheckUpdate'; + static const language = 'Language'; + static const novelFontSize = 'NovelFontSize'; + static const enableNSFW = 'EnableNSFW'; + static const videoPlayer = 'VideoPlayer'; + static const databaseVersion = 'DatabaseVersion'; + static const listMode = 'ListMode'; + static const keyI = 'KeyI'; + static const keyJ = 'KeyJ'; + static const arrowLeft = 'Arrowleft'; + static const arrowRight = 'Arrowright'; + static const readingMode = 'ReadingMode'; + static const aniListToken = 'AniListToken'; + static const aniListUserId = 'AniListUserId'; + static const autoTracking = 'AutoTracking'; + static const windowSize = 'WindowsSize'; + static const windowPosition = 'WindowsPosition'; + static const androidWebviewUA = "AndroidWebviewUA"; + static const windowsWebviewUA = "WindowsWebviewUA"; + static const proxy = "Proxy"; + static const proxyType = "ProxyType"; + static const saveLog = "SaveLog"; + static const subtitleFontSize = "SubtitleFontSize"; + static const subtitleFontWeight = "SubtitleFontWeight"; + static const subtitleFontColor = "SubtitleFontColor"; + static const subtitleBackgroundColor = "SubtitleBackgroundColor"; + static const subtitleBackgroundOpacity = "SubtitleBackgroundOpacity"; + static const subtitleTextAlign = "SubtitleTextAlign"; + static const subtitleLastLanguageSelected = "SubtitleLastLanguageSelected"; + static const subtitleLastTitleSelected = "SubtitleLastTitleSelected"; } diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index a3058fe2..dec6ea86 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -885,7 +885,7 @@ class _TrackState extends State<_Track> { widget.controller.player.state.track.subtitle, title: const Text('Off'), onPressed: () { - widget.controller.player.setSubtitleTrack( + widget.controller.setSubtitleTrack( SubtitleTrack.no(), ); router.pop(); @@ -905,7 +905,7 @@ class _TrackState extends State<_Track> { title: Text(subtitle.title ?? ''), subtitle: Text(subtitle.language ?? ''), onPressed: () { - widget.controller.player.setSubtitleTrack( + widget.controller.setSubtitleTrack( subtitle, ); router.pop(); @@ -923,7 +923,7 @@ class _TrackState extends State<_Track> { title: Text(subtitle.title ?? ''), subtitle: Text(subtitle.language ?? ''), onPressed: () { - widget.controller.player.setSubtitleTrack( + widget.controller.setSubtitleTrack( subtitle, ); router.pop(); diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index 0c4595d4..604b3ec0 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -556,6 +556,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), ], ), + const SizedBox(height: 10), const Text('Font color'), const SizedBox(height: 10), Obx( @@ -804,7 +805,7 @@ class _TrackSelector extends StatelessWidget { SubtitleTrack.no() == controller.player.state.track.subtitle, title: const Text('Off'), onTap: () { - controller.player.setSubtitleTrack( + controller.setSubtitleTrack( SubtitleTrack.no(), ); controller.showSidebar.value = false; @@ -824,7 +825,7 @@ class _TrackSelector extends StatelessWidget { title: Text(subtitle.title ?? ''), subtitle: Text(subtitle.language ?? ''), onTap: () { - controller.player.setSubtitleTrack( + controller.setSubtitleTrack( subtitle, ); controller.showSidebar.value = false; @@ -839,7 +840,7 @@ class _TrackSelector extends StatelessWidget { title: Text(subtitle.title ?? ''), subtitle: Text(subtitle.language ?? ''), onTap: () { - controller.player.setSubtitleTrack( + controller.setSubtitleTrack( subtitle, ); controller.showSidebar.value = false; From 55e54de940221cf9849f84e09b289ed764530a47 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Wed, 7 Feb 2024 23:49:16 +0800 Subject: [PATCH 13/19] Add VideoPlayerDLNA widget for DLNA device selection --- lib/controllers/watch/video_controller.dart | 155 +++++++++- .../pages/watch/video/video_player_dlna.dart | 76 +++++ .../video/video_player_mobile_controls.dart | 275 ++++++++++-------- 3 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 lib/views/pages/watch/video/video_player_dlna.dart diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 6be6a2d3..6206cc8b 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -6,6 +6,8 @@ import 'dart:io'; import 'package:auto_orientation/auto_orientation.dart'; import 'package:dio/dio.dart'; +import 'package:dlna_dart/dlna.dart'; +import 'package:dlna_dart/xmlParser.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -162,6 +164,21 @@ class VideoPlayerController extends GetxController { // 播放方式 final playMode = PlaylistMode.none.obs; + // 进度 + final position = Duration.zero.obs; + + // 总时长 + final duration = Duration.zero.obs; + + // 播放状态 + final isPlaying = false.obs; + + // dlna 设备 + final dlnaDevice = Rx(null); + + // 定时器 + Timer? _dlnaTimer; + @override void onInit() async { if (Platform.isAndroid) { @@ -273,7 +290,7 @@ class VideoPlayerController extends GetxController { } }); - //讀取現在的畫質 + // 讀取現在的畫質 player.stream.height.listen((event) async { if (player.state.width != null) { final width = player.state.width; @@ -334,6 +351,30 @@ class VideoPlayerController extends GetxController { } }); + // 总时长监听 + player.stream.duration.listen((event) { + if (dlnaDevice.value != null) { + return; + } + duration.value = event; + }); + + // 监听播放状态 + player.stream.playing.listen((event) { + if (dlnaDevice.value != null) { + return; + } + isPlaying.value = event; + }); + + // 监听进度 + player.stream.position.listen((event) { + if (dlnaDevice.value != null) { + return; + } + position.value = event; + }); + // 错误监听 player.stream.error.listen((event) { sendMessage(Message(Text(event))); @@ -365,16 +406,22 @@ class VideoPlayerController extends GetxController { error.value = e.toString(); return; } + playTorrentFile(torrentMediaFileList.first); } else { - getQuality(); - await player.open( - Media(watchData!.url, httpHeaders: watchData!.headers), - ); - if (watchData!.audioTrack != null) { - await player.setAudioTrack( - AudioTrack.uri(watchData!.audioTrack!), + if (dlnaDevice.value != null) { + await dlnaDevice.value!.setUrl(watchData!.url); + await dlnaDevice.value!.play(); + } else { + getQuality(); + await player.open( + Media(watchData!.url, httpHeaders: watchData!.headers), ); + if (watchData!.audioTrack != null) { + await player.setAudioTrack( + AudioTrack.uri(watchData!.audioTrack!), + ); + } } } isGettingWatchData.value = false; @@ -418,6 +465,7 @@ class VideoPlayerController extends GetxController { } } + // 获取 watch 数据 getWatchData() async { watchData = null; subtitles.clear(); @@ -425,6 +473,7 @@ class VideoPlayerController extends GetxController { watchData = await runtime.watch(playUrl) as ExtensionBangumiWatch; } + // 获取 torrent 媒体文件 getTorrentMediaFile() async { if (Get.find().btServerisRunning.value == false) { await BTServerUtils.startServer(); @@ -461,6 +510,7 @@ class VideoPlayerController extends GetxController { } } + // 获取画质 getQuality() async { final url = watchData!.url; final headers = watchData!.headers; @@ -535,17 +585,20 @@ class VideoPlayerController extends GetxController { } } + // 播放 torrent 媒体文件 playTorrentFile(String file) { currentTorrentFile.value = file; (player.platform as NativePlayer).setProperty("network-timeout", "60"); player.open(Media('${BTServerApi.baseApi}/torrent/$_torrenHash/$file')); } + // 切换全屏 toggleFullscreen() async { await WindowManager.instance.setFullScreen(!isFullScreen.value); isFullScreen.value = !isFullScreen.value; } + // 切换画质 switchQuality(String qualityUrl) async { final currentSecond = player.state.position.inSeconds; final headers = watchData!.headers; @@ -561,6 +614,7 @@ class VideoPlayerController extends GetxController { }); } + // 设置字幕 setSubtitleTrack(SubtitleTrack subtitle) { player.setSubtitleTrack(subtitle); MiruStorage.setSetting( @@ -573,8 +627,9 @@ class VideoPlayerController extends GetxController { ); } + // 保存历史记录 _saveHistory() async { - if (player.state.duration.inSeconds == 0) { + if (duration.value.inSeconds == 0) { return; } @@ -613,12 +668,14 @@ class VideoPlayerController extends GetxController { await Get.find().onRefresh(); } + // 判断文件是否是字幕 _isSubtitle(String file) { return file.endsWith('.srt') || file.endsWith('.vtt') || file.endsWith(".ass"); } + // 发送消息 sendMessage(Message message) { messageQueue.add(message); @@ -627,6 +684,7 @@ class VideoPlayerController extends GetxController { } } + // 处理消息提示 _processNextMessage() async { if (messageQueue.isEmpty) { cuurentMessageWidget.value = null; @@ -641,6 +699,7 @@ class VideoPlayerController extends GetxController { _processNextMessage(); } + // 切换侧边栏 toggleSideBar(SidebarTab tab) { if (showSidebar.value) { showSidebar.value = false; @@ -650,6 +709,7 @@ class VideoPlayerController extends GetxController { showSidebar.value = true; } + // 添加本地字幕文件 addSubtitleFile() async { final file = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -668,6 +728,81 @@ class VideoPlayerController extends GetxController { ); } + // 连接 DLNA 设备 + connectDLNADevice(DLNADevice device) async { + if (watchData == null) { + sendMessage(Message(Text('等待视频加载'.i18n))); + return; + } + final url = watchData!.url; + dlnaDevice.value = device; + await device.setUrl(url); + await device.play(); + await player.stop(); + _dlnaTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _getDLNAStatus(); + }); + } + + // 断开 DLNA 设备 + disconnectDLNADevice() async { + if (dlnaDevice.value == null) { + return; + } + final device = dlnaDevice.value!; + dlnaDevice.value = null; + device.stop(); + _dlnaTimer?.cancel(); + } + + // 获取 DLNA 播放状态 + _getDLNAStatus() async { + final device = dlnaDevice.value; + if (device == null) { + return; + } + final transportInfo = await device.getTransportInfo(); + if (transportInfo.contains("PLAYING")) { + isPlaying.value = true; + } else { + isPlaying.value = false; + } + final dlnaPosition = await device.position(); + final positionParser = PositionParser(dlnaPosition); + final absTimeArr = positionParser.AbsTime.split(":"); + final absTime = Duration( + hours: int.parse(absTimeArr[0]), + minutes: int.parse(absTimeArr[1]), + seconds: int.parse(absTimeArr[2]), + ); + position.value = absTime; + positionParser.TrackDurationInt; + duration.value = Duration(seconds: positionParser.TrackDurationInt); + } + + // 播放器相关操作 + playOrPause() async { + if (dlnaDevice.value == null) { + player.playOrPause(); + return; + } + if (isPlaying.value) { + await dlnaDevice.value!.pause(); + } else { + await dlnaDevice.value!.play(); + } + } + + seek(Duration duration) async { + if (dlnaDevice.value == null) { + player.seek(duration); + return; + } + final curr = await dlnaDevice.value!.position(); + final diff = duration - position.value; + await dlnaDevice.value!.seekByCurrent(curr, diff.inSeconds); + } + @override void onClose() async { if (MiruStorage.getSetting(SettingKey.autoTracking) && anilistID != "") { @@ -690,11 +825,13 @@ class VideoPlayerController extends GetxController { ]); } } + _dlnaTimer?.cancel(); player.pause(); try { await _saveHistory(); } catch (_) {} player.dispose(); + logger.info('dispose video controller'); super.onClose(); } } diff --git a/lib/views/pages/watch/video/video_player_dlna.dart b/lib/views/pages/watch/video/video_player_dlna.dart new file mode 100644 index 00000000..c7ee3799 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_dlna.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:dlna_dart/dlna.dart'; +import 'package:miru_app/utils/log.dart'; +import 'package:miru_app/views/widgets/progress.dart'; + +class VideoPlayerDLNA extends StatefulWidget { + const VideoPlayerDLNA({ + super.key, + this.onDeviceSelected, + }); + final Function(DLNADevice device)? onDeviceSelected; + + @override + State createState() => _VideoPlayerDLNAState(); +} + +class _VideoPlayerDLNAState extends State { + late DLNAManager searcher; + Map deviceList = {}; + + @override + void initState() { + super.initState(); + _init(); + } + + _init() async { + searcher = DLNAManager(); + logger.info('DLNA searching devices...'); + final m = await searcher.start(); + m.devices.stream.listen((deviceList) { + logger.info('DLNA devices: $deviceList'); + setState(() { + this.deviceList = deviceList; + }); + }); + } + + @override + void dispose() { + logger.info('DLNA stop searching devices...'); + searcher.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(left: 16), + child: Text( + 'DLNA devices', + style: TextStyle( + fontSize: 18, + ), + ), + ), + const SizedBox(height: 10), + for (final device in deviceList.entries) + ListTile( + title: Text(device.value.info.friendlyName), + subtitle: Text(device.key), + onTap: () async { + widget.onDeviceSelected?.call(device.value); + }, + ), + const Center( + child: ProgressRing(), + ) + ], + ); + } +} diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 0cb3739a..f11122ef 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -8,6 +8,7 @@ import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/utils/layout.dart'; import 'package:miru_app/utils/router.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_dlna.dart'; import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/progress.dart'; @@ -150,7 +151,7 @@ class _VideoPlayerMobileControlsState extends State { ), const Text('/'), Text( - '${_c.player.state.duration.inMinutes}:${(_c.player.state.duration.inSeconds % 60).toString().padLeft(2, '0')}', + '${_c.duration.value.inMinutes}:${(_c.duration.value.inSeconds % 60).toString().padLeft(2, '0')}', ), ], ), @@ -205,19 +206,15 @@ class _VideoPlayerMobileControlsState extends State { final dx = details.localPosition.dx; final width = LayoutUtils.width / 3; if (dx < width) { - _c.player.seek( - _c.player.state.position - const Duration(seconds: 10), + _c.seek( + _c.position.value - const Duration(seconds: 10), ); } else if (dx > width * 2) { - _c.player.seek( - _c.player.state.position + const Duration(seconds: 10), + _c.seek( + _c.position.value + const Duration(seconds: 10), ); } else { - if (_c.player.state.playing) { - _c.player.pause(); - } else { - _c.player.play(); - } + _c.playOrPause(); } }, onVerticalDragStart: (details) { @@ -241,7 +238,7 @@ class _VideoPlayerMobileControlsState extends State { setState(() {}); }, onHorizontalDragStart: (details) { - _position = _c.player.state.position; + _position = _c.position.value; }, onVerticalDragEnd: (details) { _isAdjusting = false; @@ -257,14 +254,14 @@ class _VideoPlayerMobileControlsState extends State { _position = Duration( milliseconds: pos.inMilliseconds.clamp( 0, - _c.player.state.duration.inMilliseconds, + _c.duration.value.inMilliseconds, ), ); _isSeeking = true; setState(() {}); }, onHorizontalDragEnd: (details) { - _c.player.seek(_position); + _c.seek(_position); _isSeeking = false; setState(() {}); }, @@ -341,10 +338,34 @@ class _VideoPlayerMobileControlsState extends State { _c.player.state.buffering) { return const ProgressRing(); } + if (_c.dlnaDevice.value != null) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Playing on ${_c.dlnaDevice.value!.info.friendlyName}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + FilledButton( + onPressed: () { + _c.disconnectDLNADevice(); + }, + child: Text( + 'Disconnect'.i18n, + ), + ), + ], + ); + } return const SizedBox.shrink(); }, ); } + return Card( color: Theme.of(context).colorScheme.surfaceVariant, elevation: 0, @@ -487,6 +508,34 @@ class _Header extends StatelessWidget { ); }), ), + // DLNA + IconButton( + icon: const Icon(Icons.cast), + onPressed: () { + showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + builder: (context, scrollController) { + return SingleChildScrollView( + controller: scrollController, + child: VideoPlayerDLNA( + onDeviceSelected: (device) { + controller.connectDLNADevice(device); + Get.back(); + }, + ), + ); + }, + ); + }, + ); + }, + ), // 设置按钮 IconButton( icon: const Icon(Icons.settings), @@ -535,28 +584,24 @@ class _Footer extends StatelessWidget { : null, ), ), - StreamBuilder( - stream: controller.player.stream.playing, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! || - controller.player.state.playing) { - return IconButton( - onPressed: controller.player.pause, - icon: const Icon( - Icons.pause, - size: 30, - ), - ); - } + Obx(() { + if (controller.isPlaying.value) { return IconButton( - onPressed: controller.player.play, + onPressed: controller.playOrPause, icon: const Icon( - Icons.play_arrow, + Icons.pause, size: 30, ), ); - }, - ), + } + return IconButton( + onPressed: controller.playOrPause, + icon: const Icon( + Icons.play_arrow, + size: 30, + ), + ); + }), Obx( () => IconButton( icon: const Icon(Icons.skip_next), @@ -570,44 +615,27 @@ class _Footer extends StatelessWidget { ), const SizedBox(width: 10), // 播放进度 - StreamBuilder( - stream: controller.player.stream.position, - builder: (context, snapshot) { - late Duration position; - if (snapshot.hasData) { - position = snapshot.data as Duration; - } else { - position = controller.player.state.position; - } - - return Text( - '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - ), - ); - }, - ), + Obx(() { + final position = controller.position.value; + return Text( + '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + }), const Text('/'), - StreamBuilder( - stream: controller.player.stream.duration, - builder: (context, snapshot) { - late Duration duration; - if (snapshot.hasData) { - duration = snapshot.data as Duration; - } else { - duration = controller.player.state.duration; - } - return Text( - '${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - ), - ); - }, - ), + Obx(() { + final duration = controller.duration.value; + return Text( + '${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + }), const Spacer(), // 倍速 Obx( @@ -682,35 +710,16 @@ class _SeekBar extends StatefulWidget { class _SeekBarState extends State<_SeekBar> { bool _isSliderDraging = false; - Duration _duration = Duration.zero; Duration _position = Duration.zero; Duration _buffer = Duration.zero; - StreamSubscription? _durationSubscription; - StreamSubscription? _positionSubscription; StreamSubscription? _bufferSubscription; @override void initState() { super.initState(); - _duration = widget.controller.player.state.duration; - _position = widget.controller.player.state.position; _buffer = widget.controller.player.state.buffer; - _durationSubscription = - widget.controller.player.stream.duration.listen((event) { - setState(() { - _duration = event; - }); - }); - _positionSubscription = - widget.controller.player.stream.position.listen((event) { - if (!_isSliderDraging) { - setState(() { - _position = event; - }); - } - }); _bufferSubscription = widget.controller.player.stream.buffer.listen((event) { setState(() { @@ -721,8 +730,6 @@ class _SeekBarState extends State<_SeekBar> { @override dispose() { - _durationSubscription?.cancel(); - _positionSubscription?.cancel(); _bufferSubscription?.cancel(); super.dispose(); } @@ -732,47 +739,59 @@ class _SeekBarState extends State<_SeekBar> { return SizedBox( height: 13, child: SliderTheme( - data: const SliderThemeData( - trackHeight: 2, - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: RoundSliderOverlayShape( - overlayRadius: 12, - ), + data: const SliderThemeData( + trackHeight: 2, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 6, ), - child: Slider( - min: 0, - max: _duration.inMilliseconds.toDouble(), - value: clampDouble( - _position.inMilliseconds.toDouble(), - 0, - _duration.inMilliseconds.toDouble(), - ), - secondaryTrackValue: clampDouble( - _buffer.inMilliseconds.toDouble(), - 0, - _duration.inMilliseconds.toDouble(), - ), - onChanged: (value) { - if (_isSliderDraging) { - setState(() { - _position = Duration(milliseconds: value.toInt()); - }); - } - }, - onChangeStart: (value) { - _isSliderDraging = true; - }, - onChangeEnd: (value) { - if (_isSliderDraging) { - widget.controller.player.seek( - Duration(milliseconds: value.toInt()), - ); - _isSliderDraging = false; - } - }, - )), + overlayShape: RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: Obx( + () { + final duration = widget.controller.duration.value.inMilliseconds; + int position = widget.controller.position.value.inMilliseconds; + if (_isSliderDraging) { + position = _position.inMilliseconds; + } + + return Slider( + min: 0, + max: duration.toDouble(), + value: clampDouble( + position.toDouble(), + 0, + duration.toDouble(), + ), + secondaryTrackValue: clampDouble( + _buffer.inMilliseconds.toDouble(), + 0, + duration.toDouble(), + ), + onChanged: (value) { + if (_isSliderDraging) { + setState(() { + _position = Duration(milliseconds: value.toInt()); + }); + } + }, + onChangeStart: (value) { + _position = Duration(milliseconds: value.toInt()); + _isSliderDraging = true; + }, + onChangeEnd: (value) { + if (_isSliderDraging) { + widget.controller.seek( + Duration(milliseconds: value.toInt()), + ); + _isSliderDraging = false; + } + }, + ); + }, + ), + ), ); } } From b23c5f0013591f1422e7ac622e44dcc1435fc0b0 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Thu, 8 Feb 2024 00:55:57 +0800 Subject: [PATCH 14/19] Fix subtitle merging issue --- lib/controllers/watch/video_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 6206cc8b..31fe6e71 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -336,7 +336,7 @@ class VideoPlayerController extends GetxController { return; } - final subtitle = event.subtitle.firstWhereOrNull( + final subtitle = [...event.subtitle, ...subtitles].firstWhereOrNull( (element) { if (element.id == "no" || element.id == "auto") { return false; From eba890f9d77016ee78fcee95eeb253e6db82a1d9 Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Thu, 8 Feb 2024 01:02:45 +0800 Subject: [PATCH 15/19] Update text color in Play mode --- lib/views/pages/watch/video/video_player_sidebar.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index 604b3ec0..3d0075f8 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -725,7 +725,10 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), ), const SizedBox(height: 20), - const Text("Play mode"), + Text( + 'Play mode', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), const SizedBox(height: 10), Obx( () => SegmentedButton( From 93336987a3fd30a99ab1990581546702180bc91c Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 17 Feb 2024 00:37:25 +0800 Subject: [PATCH 16/19] Add video player cast functionality --- .../{video_player_dlna.dart => video_player_cast.dart} | 10 +++++----- .../watch/video/video_player_mobile_controls.dart | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) rename lib/views/pages/watch/video/{video_player_dlna.dart => video_player_cast.dart} (87%) diff --git a/lib/views/pages/watch/video/video_player_dlna.dart b/lib/views/pages/watch/video/video_player_cast.dart similarity index 87% rename from lib/views/pages/watch/video/video_player_dlna.dart rename to lib/views/pages/watch/video/video_player_cast.dart index c7ee3799..4c2966e7 100644 --- a/lib/views/pages/watch/video/video_player_dlna.dart +++ b/lib/views/pages/watch/video/video_player_cast.dart @@ -3,18 +3,18 @@ import 'package:dlna_dart/dlna.dart'; import 'package:miru_app/utils/log.dart'; import 'package:miru_app/views/widgets/progress.dart'; -class VideoPlayerDLNA extends StatefulWidget { - const VideoPlayerDLNA({ +class VideoPlayerCast extends StatefulWidget { + const VideoPlayerCast({ super.key, this.onDeviceSelected, }); final Function(DLNADevice device)? onDeviceSelected; @override - State createState() => _VideoPlayerDLNAState(); + State createState() => _VideoPlayerCastState(); } -class _VideoPlayerDLNAState extends State { +class _VideoPlayerCastState extends State { late DLNAManager searcher; Map deviceList = {}; @@ -52,7 +52,7 @@ class _VideoPlayerDLNAState extends State { const Padding( padding: EdgeInsets.only(left: 16), child: Text( - 'DLNA devices', + 'Cast', style: TextStyle( fontSize: 18, ), diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index f11122ef..ab823387 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -8,7 +8,7 @@ import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/utils/layout.dart'; import 'package:miru_app/utils/router.dart'; -import 'package:miru_app/views/pages/watch/video/video_player_dlna.dart'; +import 'package:miru_app/views/pages/watch/video/video_player_cast.dart'; import 'package:miru_app/views/pages/watch/video/video_player_sidebar.dart'; import 'package:miru_app/views/widgets/cache_network_image.dart'; import 'package:miru_app/views/widgets/progress.dart'; @@ -523,7 +523,7 @@ class _Header extends StatelessWidget { builder: (context, scrollController) { return SingleChildScrollView( controller: scrollController, - child: VideoPlayerDLNA( + child: VideoPlayerCast( onDeviceSelected: (device) { controller.connectDLNADevice(device); Get.back(); From ad26048db3d210bc7caaf824ea0ad59f857e18af Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Sat, 17 Feb 2024 00:51:47 +0800 Subject: [PATCH 17/19] Add mobile controls for video player and quality selection button --- .../video/video_player_mobile_controls.dart | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index ab823387..0f41efca 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -637,6 +637,42 @@ class _Footer extends StatelessWidget { ); }), const Spacer(), + Obx(() { + if (controller.currentQuality.value.isEmpty) { + return const SizedBox.shrink(); + } + return FilledButton.tonal( + onPressed: () { + if (controller.qualityMap.isEmpty) { + controller.sendMessage( + Message( + const Text( + 'No quality available', + ), + ), + ); + return; + } + controller.toggleSideBar(SidebarTab.qualitys); + }, + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + ), + ), + child: Text( + controller.currentQuality.value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ); + }), + const SizedBox(width: 10), // 倍速 Obx( () => PopupMenuButton( From 09e34e99da885024715c76c02ecdd4634322b7fc Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Mon, 19 Feb 2024 00:27:12 +0800 Subject: [PATCH 18/19] Add internationalization support and update translations --- assets/i18n/en.json | 41 ++++++- assets/i18n/zh.json | 58 ++++++++- .../pages/watch/video/video_player_cast.dart | 9 +- .../video/video_player_desktop_controls.dart | 29 ++--- .../video/video_player_mobile_controls.dart | 34 ++++-- .../watch/video/video_player_sidebar.dart | 112 ++++++++++++------ 6 files changed, 209 insertions(+), 74 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 61d220d9..ba578d95 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -45,7 +45,10 @@ "login": "Login", "no-data": "No data", "clear": "Clear", - "export": "Export" + "export": "Export", + "off": "Off", + "error-message": "Error message", + "disconnect": "Disconnect" }, "home": { "continue-watching": "Continue", @@ -168,8 +171,12 @@ "subtitle-none": "No Subtitle", "subtitle": "Subtitle", "subtitle-change": "Change Subtitle {title}", - "subtitle-file": "Subtitle File", + "subtitle-file": "Add subtitle File", "torrent-downloading": "Torrent downloading", + "no-qualities": "No quality available", + "audio": "Audio", + "getting-streamlink": "Getting streamlink...", + "streamlink-error": "Failed to get streamlink", "tooltip": { "close": "Close", "subtitle": "Subtitle", @@ -183,6 +190,34 @@ "full-screen": "Full Screen", "volume": "Volume", "torrent-file-list": "Torrent file list" + }, + "cast": "Cast to device", + "cast-device": "playing on {device}", + "sidebar": { + "tab": { + "episodes": "Episodes", + "qualitys": "Qualitys", + "torrentFiles": "Torrent Files", + "tracks": "Tracks", + "settings": "Settings" + }, + "subtitle": { + "title": "Subtitle", + "font-size": "Font size", + "font-color": "Font color", + "background-color": "Background color", + "background-opacity": "Background opacity", + "text-align": "Text align", + "font-weight": "Font weight", + "font-weight-normal": "Normal", + "font-weight-bold": "Bold" + }, + "play-mode": { + "title": "Play mode", + "loop": "Loop", + "single": "Single", + "auto-next": "Auto next" + } } }, "comic-settings": { @@ -282,4 +317,4 @@ "manga-chapter-read": "Manga Chapter Read: {chapters}", "anime-episode-watch": "Anime Episode Watched: {episodes}" } -} +} \ No newline at end of file diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 9c7ba283..971085f4 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -30,7 +30,10 @@ "save-success": "保存成功", "logout": "登出", "clear": "清除", - "export": "导出" + "export": "导出", + "off": "关闭", + "error-message": "错误信息", + "disconnect": "断开连接" }, "home": { "continue-watching": "继续观看", @@ -150,8 +153,55 @@ "subtitle-none": "不使用字幕", "subtitle": "字幕", "subtitle-change": "切换字幕 {title}", - "subtitle-file": "选择字幕文件", - "torrent-downloading": "正在下载种子" + "subtitle-file": "添加字幕文件", + "torrent-downloading": "正在下载种子", + "no-qualities": "暂无可用画质", + "audio": "音轨", + "getting-streamlink": "正在获取播放链接...", + "streamlink-error": "获取播放链接失败", + "tooltip": { + "close": "关闭", + "subtitle": "字幕", + "play-list": "播放列表", + "quality": "画质", + "speed": "播放速度", + "play": "播放", + "play-or-pause": "播放/暂停", + "previous": "上一个", + "next": "下一个", + "full-screen": "全屏", + "volume": "音量", + "torrent-file-list": "种子文件列表", + "pin": "置顶窗口" + }, + "cast": "投屏到设备", + "cast-device": "正在 {device} 上播放", + "sidebar": { + "tab": { + "episodes": "剧集", + "qualitys": "画质", + "torrentFiles": "种子文件列表", + "tracks": "音轨和字幕", + "settings": "设置" + }, + "subtitle": { + "title": "字幕", + "font-size": "字体大小", + "font-color": "字体颜色", + "background-color": "背景颜色", + "background-opacity": "背景透明度", + "text-align": "文字对齐", + "font-weight": "字体粗细", + "font-weight-normal": "正常", + "font-weight-bold": "加粗" + }, + "play-mode": { + "title": "播放模式", + "single": "播完暂停", + "loop": "循环播放", + "auto-next": "自动下一集" + } + } }, "reader": { "chapters": "章节", @@ -237,4 +287,4 @@ "manga-chapter-read": "看过的漫画章节: {chapters}", "anime-episode-watch": "看过的动画剧集: {episodes}" } -} +} \ No newline at end of file diff --git a/lib/views/pages/watch/video/video_player_cast.dart b/lib/views/pages/watch/video/video_player_cast.dart index 4c2966e7..c7627938 100644 --- a/lib/views/pages/watch/video/video_player_cast.dart +++ b/lib/views/pages/watch/video/video_player_cast.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:dlna_dart/dlna.dart'; +import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/utils/log.dart'; import 'package:miru_app/views/widgets/progress.dart'; @@ -49,11 +50,11 @@ class _VideoPlayerCastState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Padding( - padding: EdgeInsets.only(left: 16), + Padding( + padding: const EdgeInsets.only(left: 16), child: Text( - 'Cast', - style: TextStyle( + 'video.cast'.i18n, + style: const TextStyle( fontSize: 18, ), ), diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index dec6ea86..b68ebd5f 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -128,9 +128,9 @@ class _VideoPlayerDesktopControlsState return Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - "Getting streamlink error", - style: TextStyle( + Text( + 'video.streamlink-error'.i18n, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), @@ -140,7 +140,7 @@ class _VideoPlayerDesktopControlsState mainAxisSize: MainAxisSize.min, children: [ Button( - child: const Text('Error message'), + child: Text('common.error-message'.i18n), onPressed: () { showDialog( context: context, @@ -148,7 +148,8 @@ class _VideoPlayerDesktopControlsState constraints: const BoxConstraints( maxWidth: 500, ), - title: const Text('Error message'), + title: + Text('common.error-message'.i18n), content: SelectableText(_c.error.value), actions: [ Button( @@ -164,7 +165,7 @@ class _VideoPlayerDesktopControlsState ), const SizedBox(width: 10), Button( - child: Text('Retry'.i18n), + child: Text('common.retry'.i18n), onPressed: () { _c.error.value = ''; _c.play(); @@ -215,9 +216,9 @@ class _VideoPlayerDesktopControlsState fontWeight: FontWeight.bold, ), ), - const Text( - 'Getting streamlink...', - style: TextStyle( + Text( + 'video.getting-streamlink'.i18n, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, ), @@ -782,7 +783,7 @@ class _QualityState extends State<_Quality> { onPressed: () { if (widget.controller.qualityMap.isEmpty) { widget.controller.sendMessage( - Message(const Text("No quality available")), + Message(Text("video.no-qualities".i18n)), ); return; } @@ -872,7 +873,7 @@ class _TrackState extends State<_Track> { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( - "Subtitles", + "video.subtitle".i18n, style: TextStyle( fontSize: 13, color: Colors.white.withAlpha(200), @@ -883,7 +884,7 @@ class _TrackState extends State<_Track> { ListTile.selectable( selected: SubtitleTrack.no() == widget.controller.player.state.track.subtitle, - title: const Text('Off'), + title: Text('common.off'.i18n), onPressed: () { widget.controller.setSubtitleTrack( SubtitleTrack.no(), @@ -892,7 +893,7 @@ class _TrackState extends State<_Track> { }, ), ListTile.selectable( - title: const Text('Add subtitle file'), + title: Text('video.subtitle-file'.i18n), onPressed: () { widget.controller.addSubtitleFile(); }, @@ -933,7 +934,7 @@ class _TrackState extends State<_Track> { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( - "Audio", + "video.audio".i18n, style: TextStyle( fontSize: 13, color: Colors.white.withAlpha(200), diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index 0f41efca..d9d71288 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:get/get.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; @@ -286,9 +287,9 @@ class _VideoPlayerMobileControlsState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - "Getting streamlink error", - style: TextStyle( + Text( + "video.streamlink-error".i18n, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), @@ -298,12 +299,12 @@ class _VideoPlayerMobileControlsState extends State { mainAxisSize: MainAxisSize.min, children: [ FilledButton( - child: const Text('Error message'), + child: Text('common.error-message'.i18n), onPressed: () { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Error message'), + title: Text('common.error-message'.i18n), content: SelectableText(_c.error.value), actions: [ FilledButton( @@ -319,7 +320,7 @@ class _VideoPlayerMobileControlsState extends State { ), const SizedBox(width: 10), FilledButton( - child: Text('Retry'.i18n), + child: Text('common.retry'.i18n), onPressed: () { _c.error.value = ''; _c.play(); @@ -343,7 +344,14 @@ class _VideoPlayerMobileControlsState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Playing on ${_c.dlnaDevice.value!.info.friendlyName}', + FlutterI18n.translate( + context, + 'video.cast-device', + translationParams: { + 'device': + _c.dlnaDevice.value!.info.friendlyName, + }, + ), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -355,7 +363,7 @@ class _VideoPlayerMobileControlsState extends State { _c.disconnectDLNADevice(); }, child: Text( - 'Disconnect'.i18n, + 'common.disconnect'.i18n, ), ), ], @@ -398,9 +406,9 @@ class _VideoPlayerMobileControlsState extends State { fontWeight: FontWeight.bold, ), ), - const Text( - 'Getting streamlink...', - style: TextStyle( + Text( + 'video.getting-streamlink'.i18n, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, ), @@ -646,8 +654,8 @@ class _Footer extends StatelessWidget { if (controller.qualityMap.isEmpty) { controller.sendMessage( Message( - const Text( - 'No quality available', + Text( + 'video.no-qualities'.i18n, ), ), ); diff --git a/lib/views/pages/watch/video/video_player_sidebar.dart b/lib/views/pages/watch/video/video_player_sidebar.dart index 3d0075f8..9e629642 100644 --- a/lib/views/pages/watch/video/video_player_sidebar.dart +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:media_kit/media_kit.dart'; import 'package:miru_app/controllers/watch/video_controller.dart'; import 'package:miru_app/utils/color.dart'; +import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/views/widgets/list_title.dart'; import 'package:miru_app/views/widgets/platform_widget.dart'; import 'package:miru_app/views/widgets/watch/playlist.dart'; @@ -16,6 +17,10 @@ enum SidebarTab { settings, } +_sidebarTabToString(SidebarTab tab) { + return "video.sidebar.tab.${tab.name}".i18n; +} + class VideoPlayerSidebar extends StatefulWidget { const VideoPlayerSidebar({ super.key, @@ -55,7 +60,9 @@ class _VideoPlayerSidebarState extends State { TabBar( tabAlignment: TabAlignment.center, isScrollable: true, - tabs: _tabs.keys.map((e) => Tab(text: e.name)).toList(), + tabs: _tabs.keys + .map((e) => Tab(text: _sidebarTabToString(e))) + .toList(), ), Expanded( child: TabBarView( @@ -81,7 +88,7 @@ class _VideoPlayerSidebarState extends State { Row( children: [ Text( - "Settings", + "common.settings".i18n, style: fluent.FluentThemeData.dark().typography.bodyLarge, ), const Spacer(), @@ -162,11 +169,11 @@ class _SideBarSettingsState extends State<_SideBarSettings> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Subtitle'), + Text('video.sidebar.subtitle.title'.i18n), const SizedBox(height: 10), Row( children: [ - const Text('Font size'), + Text('video.sidebar.subtitle.font-size'.i18n), const SizedBox(width: 10), Expanded( child: Obx( @@ -191,7 +198,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { const SizedBox(height: 10), Row( children: [ - const Text('Font color'), + Text('video.sidebar.subtitle.font-color'.i18n), const SizedBox(width: 10), fluent.SplitButton( flyout: fluent.FlyoutContent( @@ -243,7 +250,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { const SizedBox(height: 10), Row( children: [ - const Text('Background color'), + Text('video.sidebar.subtitle.background-color'.i18n), const SizedBox(width: 10), fluent.SplitButton( flyout: fluent.FlyoutContent( @@ -296,7 +303,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { const SizedBox(height: 10), Row( children: [ - const Text('Background opacity'), + Text('video.sidebar.subtitle.background-opacity'.i18n), const SizedBox(width: 10), Expanded( child: Obx( @@ -322,7 +329,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { // textAlign Row( children: [ - const Text('Text align'), + Text('video.sidebar.subtitle.text-align'.i18n), const SizedBox(width: 10), fluent.SplitButton( flyout: fluent.FlyoutContent( @@ -445,7 +452,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ], ), const SizedBox(height: 10), - const Text("Font weight"), + Text('video.sidebar.subtitle.font-weight'.i18n), const SizedBox(height: 10), Obx( () => Wrap( @@ -457,14 +464,18 @@ class _SideBarSettingsState extends State<_SideBarSettings> { onChanged: (value) { _c.subtitleFontWeight.value = FontWeight.normal; }, - child: const Text("Normal"), + child: Text( + 'video.sidebar.subtitle.font-weight-normal'.i18n, + ), ), fluent.ToggleButton( checked: _c.subtitleFontWeight.value == FontWeight.bold, onChanged: (value) { _c.subtitleFontWeight.value = FontWeight.bold; }, - child: const Text("Bold"), + child: Text( + 'video.sidebar.subtitle.font-weight-bold'.i18n, + ), ), ], ), @@ -478,7 +489,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Play mode"), + Text('video.sidebar.play-mode.title'.i18n), const SizedBox( height: 10, width: double.infinity, @@ -493,21 +504,21 @@ class _SideBarSettingsState extends State<_SideBarSettings> { onChanged: (value) { _c.playMode.value = PlaylistMode.loop; }, - child: const Text("Loop"), + child: Text('video.sidebar.play-mode.loop'.i18n), ), fluent.ToggleButton( checked: _c.playMode.value == PlaylistMode.single, onChanged: (value) { _c.playMode.value = PlaylistMode.single; }, - child: const Text("Single"), + child: Text('video.sidebar.play-mode.single'.i18n), ), fluent.ToggleButton( checked: _c.playMode.value == PlaylistMode.none, onChanged: (value) { _c.playMode.value = PlaylistMode.none; }, - child: const Text("Auto next"), + child: Text('video.sidebar.play-mode.auto-next'.i18n), ), ], ), @@ -524,11 +535,11 @@ class _SideBarSettingsState extends State<_SideBarSettings> { padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), children: [ Text( - 'Subtitle', + 'video.sidebar.subtitle.title'.i18n, style: TextStyle(color: Theme.of(context).colorScheme.primary), ), const SizedBox(height: 20), - const Text('Font size'), + Text('video.sidebar.subtitle.font-size'.i18n), const SizedBox(height: 10), Row( children: [ @@ -557,7 +568,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ], ), const SizedBox(height: 10), - const Text('Font color'), + Text('video.sidebar.subtitle.font-color'.i18n), const SizedBox(height: 10), Obx( () { @@ -596,7 +607,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { }, ), const SizedBox(height: 10), - const Text('Background color'), + Text('video.sidebar.subtitle.background-color'.i18n), const SizedBox(height: 10), Obx( () { @@ -635,7 +646,9 @@ class _SideBarSettingsState extends State<_SideBarSettings> { }, ), const SizedBox(height: 10), - const Text('Background opacity'), + Text( + 'video.sidebar.subtitle.background-opacity'.i18n, + ), const SizedBox(height: 10), Row( children: [ @@ -665,7 +678,7 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), const SizedBox(height: 10), // textAlign - const Text('Text align'), + Text('video.sidebar.subtitle.text-align'.i18n), const SizedBox(height: 10), Obx( @@ -709,14 +722,26 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), ), const SizedBox(height: 10), - const Text("Font weight"), + Text( + 'video.sidebar.subtitle.font-weight'.i18n, + ), const SizedBox(height: 10), Obx( () => SegmentedButton( showSelectedIcon: false, - segments: const [ - ButtonSegment(value: FontWeight.normal, label: Text("Normal")), - ButtonSegment(value: FontWeight.bold, label: Text("Bold")), + segments: [ + ButtonSegment( + value: FontWeight.normal, + label: Text( + 'video.sidebar.subtitle.font-weight-normal'.i18n, + ), + ), + ButtonSegment( + value: FontWeight.bold, + label: Text( + 'video.sidebar.subtitle.font-weight-bold'.i18n, + ), + ), ], selected: {_c.subtitleFontWeight.value}, onSelectionChanged: (value) { @@ -726,17 +751,32 @@ class _SideBarSettingsState extends State<_SideBarSettings> { ), const SizedBox(height: 20), Text( - 'Play mode', + 'video.sidebar.play-mode.title'.i18n, style: TextStyle(color: Theme.of(context).colorScheme.primary), ), const SizedBox(height: 10), Obx( () => SegmentedButton( showSelectedIcon: false, - segments: const [ - ButtonSegment(value: PlaylistMode.loop, label: Text("Loop")), - ButtonSegment(value: PlaylistMode.single, label: Text("Single")), - ButtonSegment(value: PlaylistMode.none, label: Text("Auto next")), + segments: [ + ButtonSegment( + value: PlaylistMode.loop, + label: Text( + 'video.sidebar.play-mode.loop'.i18n, + ), + ), + ButtonSegment( + value: PlaylistMode.single, + label: Text( + 'video.sidebar.play-mode.single'.i18n, + ), + ), + ButtonSegment( + value: PlaylistMode.none, + label: Text( + 'video.sidebar.play-mode.auto-next'.i18n, + ), + ), ], selected: {_c.playMode.value}, onSelectionChanged: (value) { @@ -800,13 +840,13 @@ class _TrackSelector extends StatelessWidget { return ListView( padding: const EdgeInsets.symmetric(vertical: 10), children: [ - const ListTitle( - title: "Subtitle", + ListTitle( + title: 'video.subtitle'.i18n, ), ListTile( selected: SubtitleTrack.no() == controller.player.state.track.subtitle, - title: const Text('Off'), + title: Text('common.off'.i18n), onTap: () { controller.setSubtitleTrack( SubtitleTrack.no(), @@ -815,7 +855,7 @@ class _TrackSelector extends StatelessWidget { }, ), ListTile( - title: const Text('Add subtitle file'), + title: Text('video.subtitle-file'.i18n), onTap: () { controller.addSubtitleFile(); controller.showSidebar.value = false; @@ -850,8 +890,8 @@ class _TrackSelector extends StatelessWidget { }, ), const SizedBox(height: 10), - const ListTitle( - title: "Audio", + ListTitle( + title: 'video.audio'.i18n, ), const SizedBox(height: 5), for (final audio in controller.player.state.tracks.audio) From ea8510a4095df9b03f1870208db1d7ac965b52de Mon Sep 17 00:00:00 2001 From: MiaoMint Date: Mon, 19 Feb 2024 00:40:05 +0800 Subject: [PATCH 19/19] Add overflow property to text styles --- lib/views/pages/watch/video/video_player_desktop_controls.dart | 2 ++ lib/views/pages/watch/video/video_player_mobile_controls.dart | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/views/pages/watch/video/video_player_desktop_controls.dart b/lib/views/pages/watch/video/video_player_desktop_controls.dart index b68ebd5f..c07660c7 100644 --- a/lib/views/pages/watch/video/video_player_desktop_controls.dart +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -318,6 +318,7 @@ class _HeaderState extends State<_Header> { style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, ), ), Text( @@ -325,6 +326,7 @@ class _HeaderState extends State<_Header> { style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/lib/views/pages/watch/video/video_player_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart index d9d71288..9441e68f 100644 --- a/lib/views/pages/watch/video/video_player_mobile_controls.dart +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -503,6 +503,7 @@ class _Header extends StatelessWidget { style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, ), ), Text( @@ -510,6 +511,7 @@ class _Header extends StatelessWidget { style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis, ), ), ],