From 300346b1b039021a0d80e61ea888556806509417 Mon Sep 17 00:00:00 2001 From: MiaoMint <44718819+MiaoMint@users.noreply.github.com> Date: Mon, 19 Feb 2024 00:40:41 +0800 Subject: [PATCH] feat: new video player (#216) * Refactor video player * Add subtitle configuration and error handling * Update sidebar variable names and subtitle font size * Fix video player bugs and improve UI * Refactor video controller and video player sidebar * Remove minimumSize property and add always on top functionality * Update color and background settings * Fix play pause button mismatch * Add volume control, screen brightness, auto orientation * Fix content-type check and dispose timer and focus node * Add color and padding to mobile controls, fix time formatting in desktop controls * Add remember subtitle settings * Add VideoPlayerDLNA widget for DLNA device selection * Fix subtitle merging issue * Update text color in Play mode * Add video player cast functionality * Add mobile controls for video player and quality selection button * Add internationalization support and update translations * Add overflow property to text styles --- assets/i18n/en.json | 41 +- assets/i18n/zh.json | 58 +- lib/controllers/watch/video_controller.dart | 883 ++++++++---- lib/main.dart | 1 - lib/utils/color.dart | 12 + lib/utils/miru_storage.dart | 64 +- lib/views/pages/watch/video/video_player.dart | 111 +- .../pages/watch/video/video_player_cast.dart | 77 ++ .../watch/video/video_player_content.dart | 498 +------ .../video/video_player_desktop_controls.dart | 1183 +++++++++++++++++ .../video/video_player_mobile_controls.dart | 843 ++++++++++++ .../watch/video/video_player_sidebar.dart | 940 +++++++++++++ lib/views/widgets/watch/playlist.dart | 31 +- pubspec.lock | 20 +- pubspec.yaml | 4 + 15 files changed, 3896 insertions(+), 870 deletions(-) create mode 100644 lib/views/pages/watch/video/video_player_cast.dart create mode 100644 lib/views/pages/watch/video/video_player_desktop_controls.dart create mode 100644 lib/views/pages/watch/video/video_player_mobile_controls.dart create mode 100644 lib/views/pages/watch/video/video_player_sidebar.dart diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 17b2f8ee..e5ee7dc7 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -46,7 +46,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", @@ -169,8 +172,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", @@ -184,6 +191,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": { @@ -283,4 +318,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/controllers/watch/video_controller.dart b/lib/controllers/watch/video_controller.dart index 81bbad86..31fe6e71 100644 --- a/lib/controllers/watch/video_controller.dart +++ b/lib/controllers/watch/video_controller.dart @@ -3,20 +3,23 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; +import 'package:auto_orientation/auto_orientation.dart'; import 'package:dio/dio.dart'; -import 'package:flutter/material.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'; -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'; @@ -27,6 +30,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; @@ -53,123 +57,230 @@ class VideoPlayerController extends GetxController { required this.anilistID, }); + // 播放器 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; - final subtitles = [].obs; - final keyboardShortcuts = {}; - final selectedSubtitle = 0.obs; - final currentQality = "".obs; - final qualityUrls = {}; + + // 快捷键 + 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 currentQuality = "".obs; + 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 方法获取到的数据 + ExtensionBangumiWatch? watchData; + final error = "".obs; + final isGettingWatchData = true.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.5.obs; + + // 侧边栏初始化 tab + final initSidebarTab = SidebarTab.episodes.obs; + + // 播放方式 + 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) { // 切换到横屏 SystemChrome.setPreferredOrientations( - [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight], + ); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await AutoOrientation.landscapeAutoMode(forceSensor: true); } - - 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'); - } + _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(); }); // 切换倍速 - ever(speed, (callback) { + ever(currentSpeed, (callback) { player.setRate(callback); }); // 显示剧集列表 - ever(showPlayList, (callback) { - if (!showPlayList.value) { + ever(showSidebar, (callback) { + if (!showSidebar.value) { 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) { - 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; @@ -178,22 +289,15 @@ 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) { final width = player.state.width; - currentQality.value = "${width}x$event"; + currentQuality.value = "${width}x$event"; } }); + // 自动恢复上次播放进度 player.stream.duration.listen((event) async { if (_isAutoSeekPosition || event.inSeconds == 0) { @@ -216,163 +320,141 @@ 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, ...subtitles].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.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))); }); - - 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 { // 如果已经 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 { - subtitles.clear(); - selectedSubtitle.value = -1; - final playUrl = playList[index.value].url; - final watchData = await runtime.watch(playUrl) as ExtensionBangumiWatch; - videoheaders = watchData.headers; - - if (watchData.type == ExtensionWatchBangumiType.torrent) { - if (Get.find().btServerisRunning.value == false) { - await BTServerUtils.startServer(); + if (watchData!.type == ExtensionWatchBangumiType.torrent) { + try { + await getTorrentMediaFile(); + } catch (e) { + logger.severe(e); + error.value = e.toString(); + return; } - 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( - ExtensionBangumiWatchSubtitle( - 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, - ), + 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!), ); - 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!)); } } - subtitles.addAll(watchData.subtitles ?? []); - } catch (e) { - 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; + isGettingWatchData.value = false; + // 添加来自扩展的字幕 + subtitles.addAll( + (watchData!.subtitles ?? []).map( + (e) => SubtitleTrack.uri( + e.url, + language: e.language, + title: e.title, + ), + ), + ); + player.setSubtitleTrack(SubtitleTrack.no()); + } on StartServerException catch (_) { + // 如果是 启动 bt server 失败 + 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()), @@ -383,40 +465,171 @@ class VideoPlayerController extends GetxController { } } + // 获取 watch 数据 + getWatchData() async { + watchData = null; + subtitles.clear(); + final playUrl = playList[index.value].url; + watchData = await runtime.watch(playUrl) as ExtensionBangumiWatch; + } + + // 获取 torrent 媒体文件 + 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!.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')?.toLowerCase(); + if (contentType == null || + !contentType.contains('mpegurl') && + !contentType.contains('m3u8') && + !contentType.contains('mp2t')) { + 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 { + 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, + ), + ); + } + } + + // 播放 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; - 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!.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 { - if (_torrenHash.isNotEmpty) { - BTServerApi.removeTorrent(_torrenHash); - } + // 设置字幕 + setSubtitleTrack(SubtitleTrack subtitle) { + player.setSubtitleTrack(subtitle); + MiruStorage.setSetting( + SettingKey.subtitleLastLanguageSelected, + subtitle.language, + ); + MiruStorage.setSetting( + SettingKey.subtitleLastTitleSelected, + subtitle.title, + ); + } - if (player.state.duration.inSeconds == 0) { + // 保存历史记录 + _saveHistory() async { + if (duration.value.inSeconds == 0) { return; } @@ -431,35 +644,38 @@ 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); + + logger.info('save history'); + + 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) { return file.endsWith('.srt') || file.endsWith('.vtt') || file.endsWith(".ass"); } + // 发送消息 sendMessage(Message message) { messageQueue.add(message); @@ -468,6 +684,7 @@ class VideoPlayerController extends GetxController { } } + // 处理消息提示 _processNextMessage() async { if (messageQueue.isEmpty) { cuurentMessageWidget.value = null; @@ -482,23 +699,112 @@ class VideoPlayerController extends GetxController { _processNextMessage(); } - @override - void onClose() { - if (Platform.isAndroid) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.edgeToEdge, - ); - // 如果是平板则不改变 - if (LayoutUtils.isTablet) { - return; - } - // 切换回竖屏 - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); + // 切换侧边栏 + 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, + ), + ); + } + + // 连接 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 != "") { AniListProvider.editList( status: AnilistMediaListStatus.current, @@ -506,7 +812,26 @@ class VideoPlayerController extends GetxController { mediaId: anilistID, ); } - + if (Platform.isAndroid) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + // 如果是平板则不改变 + // 切换回竖屏 + if (!LayoutUtils.isTablet) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + } + _dlnaTimer?.cancel(); + player.pause(); + try { + await _saveHistory(); + } catch (_) {} + player.dispose(); + logger.info('dispose video controller'); super.onClose(); } } 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/utils/color.dart b/lib/utils/color.dart index faaf6d8f..c562ae42 100644 --- a/lib/utils/color.dart +++ b/lib/utils/color.dart @@ -19,4 +19,16 @@ class ColorUtils { ][colorIndex]; return color!; } + + static List baseColors = [ + Colors.white, + Colors.black, + Colors.red, + Colors.orange, + Colors.yellow, + Colors.green, + Colors.cyan, + Colors.blue, + Colors.purple, + ]; } 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.dart b/lib/views/pages/watch/video/video_player.dart index 86e39e65..a86df079 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'; @@ -54,7 +54,6 @@ class _VideoPlayerState extends State { @override void dispose() { - _c.player.dispose(); Get.delete(tag: widget.title); super.dispose(); } @@ -62,66 +61,55 @@ class _VideoPlayerState extends State { _buildContent() { return Obx(() { final maxWidth = MediaQuery.of(context).size.width; - return PopScope( - onPopInvoked: (_) async { - await _c.onExit(); - }, - child: Row( - children: [ - AnimatedContainer( - onEnd: () { - _c.isOpenSidebar.value = _c.showPlayList.value; - }, - width: _c.showPlayList.value - ? MediaQuery.of(context).size.width - 300 - : maxWidth, - duration: const Duration(milliseconds: 120), - child: Stack( - children: [ - 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: 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; - }, - ), - ) - ], - ), + ), + if (_c.isOpenSidebar.value) + Expanded( + child: VideoPlayerSidebar( + controller: _c, + ), + ) + ], ); }); } @@ -129,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_cast.dart b/lib/views/pages/watch/video/video_player_cast.dart new file mode 100644 index 00000000..c7627938 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_cast.dart @@ -0,0 +1,77 @@ +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'; + +class VideoPlayerCast extends StatefulWidget { + const VideoPlayerCast({ + super.key, + this.onDeviceSelected, + }); + final Function(DLNADevice device)? onDeviceSelected; + + @override + State createState() => _VideoPlayerCastState(); +} + +class _VideoPlayerCastState 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: [ + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + 'video.cast'.i18n, + style: const 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_content.dart b/lib/views/pages/watch/video/video_player_content.dart index 18c5d9cd..7f4e2b45 100644 --- a/lib/views/pages/watch/video/video_player_content.dart +++ b/lib/views/pages/watch/video/video_player_content.dart @@ -4,494 +4,34 @@ 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/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, }); 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) { - 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, - ), - ); - } - - Widget _buildAndroid(BuildContext context) { - return MaterialVideoControlsTheme( - normal: MaterialVideoControlsThemeData( - volumeGesture: true, - brightnessGesture: true, - topButtonBar: [Expanded(child: topButtonBar)], - 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.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( - 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, + final c = Get.find(tag: tag); + return Video( + controller: c.videoController, + subtitleViewConfiguration: const SubtitleViewConfiguration( + visible: false, + ), + controls: (state) { + if (Platform.isAndroid) { + return VideoPlayerMobileControls( + controller: c, + ); + } + return VideoPlayerDesktopControls( + 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 new file mode 100644 index 00000000..c07660c7 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_desktop_controls.dart @@ -0,0 +1,1183 @@ +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: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'; + +class VideoPlayerDesktopControls extends StatefulWidget { + const VideoPlayerDesktopControls({ + super.key, + required this.controller, + }); + final VideoPlayerController controller; + + @override + State createState() => + _VideoPlayerDesktopControlsState(); +} + +class _VideoPlayerDesktopControlsState + extends State { + late final _c = widget.controller; + final FocusNode _focusNode = FocusNode(); + Timer? _timer; + bool _showControls = true; + final _subtitleViewKey = GlobalKey(); + + _updateTimer() { + _timer?.cancel(); + _timer = null; + setState(() { + _showControls = true; + }); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) { + if (mounted) { + setState(() { + _showControls = false; + }); + } + }, + ); + } + + @override + void initState() { + super.initState(); + _updateTimer(); + } + + @override + void dispose() { + _timer?.cancel(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onHover: (_) { + _updateTimer(); + }, + 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: 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.withOpacity( + _c.subtitleBackgroundOpacity.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( + child: Obx(() { + if (_c.error.value.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'video.streamlink-error'.i18n, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Button( + child: Text('common.error-message'.i18n), + onPressed: () { + showDialog( + context: context, + builder: (context) => ContentDialog( + constraints: const BoxConstraints( + maxWidth: 500, + ), + title: + Text('common.error-message'.i18n), + content: SelectableText(_c.error.value), + actions: [ + Button( + child: Text('common.close'.i18n), + onPressed: () { + router.pop(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + Button( + child: Text('common.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, + ), + ), + Text( + 'video.getting-streamlink'.i18n, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) + ], + ), + ); + }), + ), + ), + ), + Positioned.fill( + child: Column( + children: [ + // header + Opacity( + opacity: _showControls ? 1 : 0, + child: _Header( + title: _c.title, + episode: _c.playList[_c.index.value].name, + onClose: () { + if (_c.isFullScreen.value) { + WindowManager.instance.setFullScreen(false); + } + router.pop(); + }, + ), + ), + // center + const Spacer(), + // footer + Opacity( + opacity: _showControls ? 1 : 0, + child: _Footer(controller: _c), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Header extends StatefulWidget { + const _Header({ + required this.title, + required this.episode, + required this.onClose, + }); + final String title; + 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( + 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( + widget.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + widget.episode, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + // 置顶 + 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, + ), + onPressed: () { + WindowManager.instance.minimize(); + }, + ), + const SizedBox(width: 10), + IconButton( + onPressed: widget.onClose, + icon: const Icon( + FluentIcons.chevron_down, + ), + ), + ], + ), + ), + ); + } +} + +class _Footer extends StatelessWidget { + const _Footer({ + 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).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(width: 20), + Expanded( + child: _SeekBar(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).toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 10), + 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.currentQuality.value.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(left: 10), + child: _Quality(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! || + controller.player.state.playing) { + 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, + ), + ), + ), + ], + ), + ), + 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), + + // 剧集 + _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.showSidebar.value; + controller.showSidebar.value = !showPlayList; + }, + icon: const Icon( + FluentIcons.settings, + ), + ), + ], + ), + ), + ], + ); + }) + ], + ), + ), + ); + } +} + +class _Volume extends StatefulWidget { + const _Volume({ + required this.value, + required this.onVolumeChanged, + }); + final double value; + final Function(double value) onVolumeChanged; + + @override + State<_Volume> createState() => _VolumeState(); +} + +class _VolumeState extends State<_Volume> { + 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 _Episode extends StatefulWidget { + const _Episode({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_Episode> createState() => _EpisodeState(); +} + +class _EpisodeState extends State<_Episode> { + 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 _Quality extends StatefulWidget { + const _Quality({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_Quality> createState() => _QualityState(); +} + +class _QualityState extends State<_Quality> { + final controller = FlyoutController(); + + @override + dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlyoutTarget( + controller: controller, + child: Button( + child: Text(widget.controller.currentQuality.value), + onPressed: () { + if (widget.controller.qualityMap.isEmpty) { + widget.controller.sendMessage( + Message(Text("video.no-qualities".i18n)), + ); + 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 _Track extends StatefulWidget { + const _Track({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_Track> createState() => _TrackState(); +} + +class _TrackState extends State<_Track> { + 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( + "video.subtitle".i18n, + 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: Text('common.off'.i18n), + onPressed: () { + widget.controller.setSubtitleTrack( + SubtitleTrack.no(), + ); + router.pop(); + }, + ), + ListTile.selectable( + title: Text('video.subtitle-file'.i18n), + onPressed: () { + widget.controller.addSubtitleFile(); + }, + ), + // 来自扩展的字幕 + 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.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.setSubtitleTrack( + subtitle, + ); + router.pop(); + }, + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + "video.audio".i18n, + 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 _TorrentFiles extends StatefulWidget { + const _TorrentFiles({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_TorrentFiles> createState() => _TorrentFilesState(); +} + +class _TorrentFilesState extends State<_TorrentFiles> { + 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 _Speed extends StatefulWidget { + const _Speed({ + required this.controller, + }); + + final VideoPlayerController controller; + + @override + State<_Speed> createState() => _SpeedState(); +} + +class _SpeedState extends State<_Speed> { + 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 _SeekBar extends StatefulWidget { + const _SeekBar({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + State<_SeekBar> createState() => _SeekBarState(); +} + +class _SeekBarState extends State<_SeekBar> { + Duration position = const Duration(); + Duration duration = const Duration(); + bool _isDrag = false; + StreamSubscription? positionSubscription; + StreamSubscription? durationSubscription; + + @override + void initState() { + super.initState(); + positionSubscription = + widget.controller.player.stream.position.listen((event) { + if (!_isDrag) { + position = 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 < position.inSeconds + ? position.inSeconds.toDouble() + : duration.inSeconds.toDouble(), + label: + '${position.inMinutes}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', + 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_mobile_controls.dart b/lib/views/pages/watch/video/video_player_mobile_controls.dart new file mode 100644 index 00000000..9441e68f --- /dev/null +++ b/lib/views/pages/watch/video/video_player_mobile_controls.dart @@ -0,0 +1,843 @@ +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'; +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_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'; +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}); + final VideoPlayerController controller; + + @override + State createState() => + _VideoPlayerMobileControlsState(); +} + +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; + }); + } + }, + ); + } + + _init() async { + _updateTimer(); + VolumeController().showSystemUI = false; + _currentBrightness = await ScreenBrightness().current; + _currentVolume = await VolumeController().getVolume(); + } + + @override + void initState() { + _init(); + super.initState(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: const TextStyle( + color: Colors.white, + ), + child: Theme( + data: ThemeData.dark(useMaterial3: true), + child: Stack( + children: [ + // 字幕 + 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.withOpacity( + _c.subtitleBackgroundOpacity.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( + 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.duration.value.inMinutes}:${(_c.duration.value.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: () { + if (_showControls) { + _showControls = false; + setState(() {}); + return; + } + _updateTimer(); + }, + onDoubleTapDown: (details) { + // 如果左边点击快退,中间暂停,右边快进 + final dx = details.localPosition.dx; + final width = LayoutUtils.width / 3; + if (dx < width) { + _c.seek( + _c.position.value - const Duration(seconds: 10), + ); + } else if (dx > width * 2) { + _c.seek( + _c.position.value + const Duration(seconds: 10), + ); + } else { + _c.playOrPause(); + } + }, + 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.position.value; + }, + 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.duration.value.inMilliseconds, + ), + ); + _isSeeking = true; + setState(() {}); + }, + onHorizontalDragEnd: (details) { + _c.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(), + ), + ), + // 中间显示 + Positioned.fill( + child: Center( + child: Obx(() { + if (_c.error.value.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "video.streamlink-error".i18n, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + child: Text('common.error-message'.i18n), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('common.error-message'.i18n), + content: SelectableText(_c.error.value), + actions: [ + FilledButton( + child: Text('common.close'.i18n), + onPressed: () { + Get.back(); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + FilledButton( + child: Text('common.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(); + } + if (_c.dlnaDevice.value != null) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + FlutterI18n.translate( + context, + 'video.cast-device', + translationParams: { + 'device': + _c.dlnaDevice.value!.info.friendlyName, + }, + ), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + FilledButton( + onPressed: () { + _c.disconnectDLNADevice(); + }, + child: Text( + 'common.disconnect'.i18n, + ), + ), + ], + ); + } + return const SizedBox.shrink(); + }, + ); + } + + return Card( + color: Theme.of(context).colorScheme.surfaceVariant, + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(10), + 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, + ), + ), + Text( + 'video.getting-streamlink'.i18n, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + ), + ), + ], + ) + ], + ), + ), + ); + }), + ), + ), + // 头部控制栏 + if (_showControls) + Positioned( + top: 0, + left: 0, + right: 0, + child: _Header( + controller: _c, + ), + ), + // 底部控制栏 + if (_showControls) + Positioned( + bottom: 0, + left: 0, + right: 0, + 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; + }, + ); + }, + ), + ) + ], + ), + ), + ); + } +} + +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.black54, + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + RouterUtils.pop(); + }, + ), + const SizedBox(width: 10), + 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, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + episode, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + }), + ), + // 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: VideoPlayerCast( + onDeviceSelected: (device) { + controller.connectDLNADevice(device); + Get.back(); + }, + ), + ); + }, + ); + }, + ); + }, + ), + // 设置按钮 + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + controller.toggleSideBar(SidebarTab.settings); + }, + ), + ], + ), + ); + } +} + +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: [ + _SeekBar(controller: controller), + const SizedBox(height: 10), + Row( + children: [ + Obx( + () => IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: controller.index.value > 0 + ? () { + controller.index.value--; + } + : null, + ), + ), + Obx(() { + if (controller.isPlaying.value) { + return IconButton( + onPressed: controller.playOrPause, + icon: const Icon( + 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), + onPressed: + controller.playList.length - 1 > controller.index.value + ? () { + controller.index.value++; + } + : null, + ), + ), + const SizedBox(width: 10), + // 播放进度 + 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('/'), + 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(() { + if (controller.currentQuality.value.isEmpty) { + return const SizedBox.shrink(); + } + return FilledButton.tonal( + onPressed: () { + if (controller.qualityMap.isEmpty) { + controller.sendMessage( + Message( + Text( + 'video.no-qualities'.i18n, + ), + ), + ); + 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( + 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, + ), + ), + ), + ), + // 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); + }, + icon: const Icon( + Icons.subtitles, + ), + ), + // 播放列表 + IconButton( + icon: const Icon(Icons.playlist_play), + onPressed: () { + controller.toggleSideBar(SidebarTab.episodes); + }, + ), + ], + ), + ], + ), + ); + } +} + +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 _position = Duration.zero; + Duration _buffer = Duration.zero; + + StreamSubscription? _bufferSubscription; + + @override + void initState() { + super.initState(); + _buffer = widget.controller.player.state.buffer; + + _bufferSubscription = + widget.controller.player.stream.buffer.listen((event) { + setState(() { + _buffer = event; + }); + }); + } + + @override + dispose() { + _bufferSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 13, + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 2, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + 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; + } + }, + ); + }, + ), + ), + ); + } +} 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..9e629642 --- /dev/null +++ b/lib/views/pages/watch/video/video_player_sidebar.dart @@ -0,0 +1,940 @@ +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/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'; + +enum SidebarTab { + episodes, + qualitys, + torrentFiles, + tracks, + settings, +} + +_sidebarTabToString(SidebarTab tab) { + return "video.sidebar.tab.${tab.name}".i18n; +} + +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 = { + SidebarTab.episodes: PlayList( + title: _c.title, + list: _c.playList.map((e) => e.name).toList(), + selectIndex: _c.index.value, + onChange: (value) { + _c.index.value = value; + _c.showSidebar.value = false; + }, + ), + }; + + Widget _buildAndroid(BuildContext context) { + 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: _sidebarTabToString(e))) + .toList(), + ), + Expanded( + child: TabBarView( + children: _tabs.values.toList(), + ), + ), + ], + ), + ), + ); + } + + 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(10), + children: [ + Row( + children: [ + Text( + "common.settings".i18n, + 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[SidebarTab.settings]! + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_c.torrentMediaFileList.isNotEmpty) { + _tabs.addAll( + { + SidebarTab.torrentFiles: _TorrentFiles( + controller: _c, + ), + }, + ); + } + + 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, + ); + } +} + +class _SideBarSettings extends StatefulWidget { + const _SideBarSettings({ + required this.controller, + }); + final VideoPlayerController controller; + + @override + State<_SideBarSettings> createState() => _SideBarSettingsState(); +} + +class _SideBarSettingsState extends State<_SideBarSettings> { + late final _c = widget.controller; + + Widget _buildDesktop(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + fluent.Card( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('video.sidebar.subtitle.title'.i18n), + const SizedBox(height: 10), + Row( + children: [ + Text('video.sidebar.subtitle.font-size'.i18n), + 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: [ + Text('video.sidebar.subtitle.font-color'.i18n), + 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: [ + ...ColorUtils.baseColors.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, + ), + ); + }), + ], + ), + ), + ), + child: Obx( + () => Container( + decoration: BoxDecoration( + color: _c.subtitleFontColor.value, + borderRadius: + const BorderRadiusDirectional.horizontal( + start: Radius.circular(4.0), + ), + ), + height: 32, + width: 36, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Text('video.sidebar.subtitle.background-color'.i18n), + 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: [ + ...ColorUtils.baseColors.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: [ + Text('video.sidebar.subtitle.background-opacity'.i18n), + const SizedBox(width: 10), + Expanded( + child: Obx( + () => fluent.Slider( + value: _c.subtitleBackgroundOpacity.value, + onChanged: (value) { + _c.subtitleBackgroundOpacity.value = value; + }, + min: 0, + max: 1, + ), + ), + ), + const SizedBox(width: 10), + Obx( + () => Text( + _c.subtitleBackgroundOpacity.value.toStringAsFixed(2), + ), + ), + ], + ), + const SizedBox(height: 10), + // textAlign + Row( + children: [ + Text('video.sidebar.subtitle.text-align'.i18n), + 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, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text('video.sidebar.subtitle.font-weight'.i18n), + 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: Text( + 'video.sidebar.subtitle.font-weight-normal'.i18n, + ), + ), + fluent.ToggleButton( + checked: _c.subtitleFontWeight.value == FontWeight.bold, + onChanged: (value) { + _c.subtitleFontWeight.value = FontWeight.bold; + }, + child: Text( + 'video.sidebar.subtitle.font-weight-bold'.i18n, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 20), + fluent.Card( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('video.sidebar.play-mode.title'.i18n), + 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: Text('video.sidebar.play-mode.loop'.i18n), + ), + fluent.ToggleButton( + checked: _c.playMode.value == PlaylistMode.single, + onChanged: (value) { + _c.playMode.value = PlaylistMode.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: Text('video.sidebar.play-mode.auto-next'.i18n), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildAndroid(BuildContext context) { + return ListView( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + children: [ + Text( + 'video.sidebar.subtitle.title'.i18n, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 20), + Text('video.sidebar.subtitle.font-size'.i18n), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Obx( + () => SliderTheme( + data: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + ), + child: Slider( + value: _c.subtitleFontSize.value, + onChanged: (value) { + _c.subtitleFontSize.value = value; + }, + min: 20, + max: 80, + ), + ), + ), + ), + Obx( + () => Text( + _c.subtitleFontSize.value.toStringAsFixed(0), + ), + ), + ], + ), + const SizedBox(height: 10), + Text('video.sidebar.subtitle.font-color'.i18n), + 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, + ), + ), + const SizedBox(width: 10) + ], + ], + ), + ); + }, + ), + const SizedBox(height: 10), + Text('video.sidebar.subtitle.background-color'.i18n), + 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, + ), + ), + const SizedBox(width: 10) + ], + ], + ), + ); + }, + ), + const SizedBox(height: 10), + Text( + 'video.sidebar.subtitle.background-opacity'.i18n, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Obx( + () => SliderTheme( + data: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + ), + child: 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 + Text('video.sidebar.subtitle.text-align'.i18n), + 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), + Text( + 'video.sidebar.subtitle.font-weight'.i18n, + ), + const SizedBox(height: 10), + Obx( + () => SegmentedButton( + showSelectedIcon: false, + 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) { + _c.subtitleFontWeight.value = value.first; + }, + ), + ), + const SizedBox(height: 20), + Text( + 'video.sidebar.play-mode.title'.i18n, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 10), + Obx( + () => SegmentedButton( + showSelectedIcon: false, + 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) { + _c.playMode.value = value.first; + }, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +} + +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: [ + ListTitle( + title: 'video.subtitle'.i18n, + ), + ListTile( + selected: + SubtitleTrack.no() == controller.player.state.track.subtitle, + title: Text('common.off'.i18n), + onTap: () { + controller.setSubtitleTrack( + SubtitleTrack.no(), + ); + controller.showSidebar.value = false; + }, + ), + ListTile( + title: Text('video.subtitle-file'.i18n), + 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.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.setSubtitleTrack( + subtitle, + ); + controller.showSidebar.value = false; + }, + ), + const SizedBox(height: 10), + ListTitle( + title: 'video.audio'.i18n, + ), + 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; + }, + ), + ], + ); + } +} + +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; + }, + ), + ], + ); + } +} 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, + ); + }, ); } 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: