diff --git a/lib/main.dart b/lib/main.dart index f369a72..5916823 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,12 +2,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:media_kit/media_kit.dart'; import 'core/storage/storage_manager.dart'; import 'core/http/dio_client.dart'; import 'app/app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); // Init Hive for persistent storage await StorageManager.init(); diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index bee1548..d21f73c 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -11,6 +11,7 @@ import '../models/media_library.dart'; import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; import '../providers/media_library_provider.dart'; +import '../widgets/media_player_dialog.dart'; enum _MediaWallFilter { all, movies, series, collections, unmatched } @@ -445,9 +446,14 @@ class _MediaLibraryPageState extends ConsumerState { onBack: () => setState(() => _detailWork = null), onDownload: (item) => ref.read(fileProvider.notifier).downloadFile(item.file), - onPlay: (item) => ref.read(fileProvider.notifier).playFile(item.file), - onPlayInBrowser: (item) => - ref.read(fileProvider.notifier).playInBrowser(item.file), + onPlay: (item) => showShadDialog( + context: context, + builder: (_) => MediaPlayerDialog(file: item.file), + ), + onExternalPlay: (item) => showShadDialog( + context: context, + builder: (_) => ExternalPlayerDialog(file: item.file), + ), onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!), onRescan: state.isScanning ? null @@ -1783,7 +1789,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { final VoidCallback onBack; final ValueChanged onDownload; final ValueChanged onPlay; - final ValueChanged onPlayInBrowser; + final ValueChanged onExternalPlay; final VoidCallback onManualMatch; final VoidCallback? onRescan; @@ -1792,7 +1798,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { required this.onBack, required this.onDownload, required this.onPlay, - required this.onPlayInBrowser, + required this.onExternalPlay, required this.onManualMatch, this.onRescan, }); @@ -1848,9 +1854,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { child: const Text('播放'), ), ShadButton.outline( - onPressed: () => widget.onPlayInBrowser(_resource), - leading: const Icon(Icons.open_in_browser_rounded, size: 16), - child: const Text('浏览器播放'), + onPressed: () => widget.onExternalPlay(_resource), + leading: const Icon(Icons.launch_rounded, size: 16), + child: const Text('外部播放器'), ), ShadButton.outline( onPressed: () => widget.onDownload(_resource), @@ -2050,9 +2056,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { child: const Text('播放'), ), ShadContextMenuItem.inset( - leading: const Icon(LucideIcons.externalLink, size: 16), - onPressed: () => widget.onPlayInBrowser(resource), - child: const Text('浏览器播放'), + leading: const Icon(LucideIcons.monitorPlay, size: 16), + onPressed: () => widget.onExternalPlay(resource), + child: const Text('外部播放器'), ), ShadContextMenuItem.inset( leading: const Icon(LucideIcons.download, size: 16), diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index 0a1015e..3f77138 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -10,6 +10,13 @@ import '../models/cloud_file.dart'; enum FileSort { name, size, modifiedAt, createdAt, type } +class ExternalPlayer { + final String name; + final String bundleID; + + const ExternalPlayer(this.name, this.bundleID); +} + extension FileSortExt on FileSort { String get title { switch (this) { @@ -655,14 +662,65 @@ class FileNotifier extends StateNotifier { ); } - Future playInBrowser(CloudFile file) async { - await _openRemoteFile( - file, - preparingMessage: '正在准备浏览器播放…', - completedMessage: '已在外部浏览器打开播放链接', - ); + static const supportedExternalPlayers = [ + ExternalPlayer('IINA', 'com.colliderli.iina'), + ExternalPlayer('VLC', 'org.videolan.vlc'), + ExternalPlayer('Infuse', 'com.firecore.Infuse'), + ExternalPlayer('nPlayer', 'com.nplayer.nplayer'), + ExternalPlayer('Movist Pro', 'com.movist.MovistPro'), + ExternalPlayer('VidHub', 'com.mac.utility.media.hub'), + ExternalPlayer('Forward', 'flux.inchmade.app'), + ExternalPlayer('SenPlayer', 'com.wuziqi.SenPlayer'), + ExternalPlayer('PotPlayer', 'com.kakao.PotPlayer'), + ExternalPlayer('mpv', 'io.mpv'), + ]; + + Future> availableExternalPlayers() async { + if (!Platform.isMacOS) return const []; + final installed = []; + for (final player in supportedExternalPlayers) { + try { + final result = await Process.run('/usr/bin/open', ['-Ra', player.name]); + if (result.exitCode == 0) installed.add(player); + } catch (_) { + // A missing application is expected and should not affect playback. + } + } + return installed; } + Future playWithExternalPlayer( + CloudFile file, [ + ExternalPlayer? player, + ]) async { + if (_api == null) return; + try { + state = state.copyWith( + statusMessage: player == null ? '正在准备外部播放…' : '正在使用 ${player.name} 播放…', + ); + final url = await _resolveOpenUrl(file); + if (Platform.isMacOS && player != null) { + final result = await Process.run('/usr/bin/open', [ + '-b', + player.bundleID, + url.toString(), + ]); + if (result.exitCode != 0) { + throw Exception('无法启动 ${player.name}:${result.stderr}'); + } + } else if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('无法调用系统默认外部播放器'); + } + state = state.copyWith( + statusMessage: player == null ? '已交给外部播放器' : '已交给 ${player.name}', + ); + } catch (e) { + state = state.copyWith(errorMessage: e.toString()); + } + } + + Future playbackUrl(CloudFile file) => _resolveOpenUrl(file); + Future _openRemoteFile( CloudFile file, { required String preparingMessage, diff --git a/lib/widgets/media_player_dialog.dart b/lib/widgets/media_player_dialog.dart new file mode 100644 index 0000000..adb982a --- /dev/null +++ b/lib/widgets/media_player_dialog.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../models/cloud_file.dart'; +import '../providers/file_provider.dart'; + +class MediaPlayerDialog extends ConsumerStatefulWidget { + final CloudFile file; + + const MediaPlayerDialog({super.key, required this.file}); + + @override + ConsumerState createState() => _MediaPlayerDialogState(); +} + +class _MediaPlayerDialogState extends ConsumerState { + late final Player _player; + late final VideoController _controller; + String? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _player = Player(); + _controller = VideoController(_player); + Future.microtask(_open); + } + + Future _open() async { + try { + final url = await ref + .read(fileProvider.notifier) + .playbackUrl(widget.file); + await _player.open(Media(url.toString()), play: true); + } catch (error) { + _error = error.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadDialog( + title: Text( + widget.file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + description: const Text('内置播放器'), + actions: [ + ShadButton.outline( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + child: SizedBox( + width: 960, + height: 610, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ColoredBox( + color: Colors.black, + child: _loading + ? const Center(child: ShadProgress()) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + '无法播放:$_error', + textAlign: TextAlign.center, + style: TextStyle(color: cs.destructive), + ), + ), + ) + : Video(controller: _controller), + ), + ), + ), + ); + } +} + +class ExternalPlayerDialog extends ConsumerStatefulWidget { + final CloudFile file; + + const ExternalPlayerDialog({super.key, required this.file}); + + @override + ConsumerState createState() => + _ExternalPlayerDialogState(); +} + +class _ExternalPlayerDialogState extends ConsumerState { + late final Future> _players; + + @override + void initState() { + super.initState(); + _players = ref.read(fileProvider.notifier).availableExternalPlayers(); + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadDialog( + title: const Text('选择外部播放器'), + description: Text( + widget.file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: [ + ShadButton.outline( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ], + child: SizedBox( + width: 420, + child: FutureBuilder>( + future: _players, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox( + height: 100, + child: Center(child: ShadProgress()), + ); + } + final players = snapshot.data!; + if (players.isEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '未发现支持的外部播放器', + style: TextStyle(color: cs.mutedForeground), + ), + const SizedBox(height: 12), + ShadButton.outline( + onPressed: () { + ref + .read(fileProvider.notifier) + .playWithExternalPlayer(widget.file); + Navigator.of(context).pop(); + }, + leading: const Icon(Icons.play_arrow_rounded, size: 16), + child: const Text('使用系统默认播放器'), + ), + ], + ); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final player in players) + ShadButton.outline( + onPressed: () { + ref + .read(fileProvider.notifier) + .playWithExternalPlayer(widget.file, player); + Navigator.of(context).pop(); + }, + leading: const Icon( + Icons.play_circle_outline_rounded, + size: 16, + ), + child: Text(player.name), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0f3f9ac..f8155aa 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,12 +9,15 @@ import desktop_drop import device_info_plus import file_picker import file_selector_macos +import media_kit_libs_macos_video +import media_kit_video import package_info_plus import pasteboard import screen_retriever_macos import share_plus import sqflite_darwin import url_launcher_macos +import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -22,11 +25,14 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 57f9975..ff0851f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -805,6 +805,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: @@ -1125,6 +1189,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "7483b3d5e8976f0bd263647c03b96131ee8e43f48b56fa8a8ec459e8515d74b0" + url: "https://pub.dev" + source: hosted + version: "2.0.4" screen_retriever: dependency: transitive description: @@ -1450,6 +1522,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -1562,6 +1642,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a5214d0..b15a1b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,9 @@ dependencies: image_picker: ^1.2.2 qr_flutter: ^4.1.0 desktop_drop: ^0.7.1 + media_kit: ^1.2.4 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.6 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 418dbce..50d8e08 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -20,6 +22,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopDropPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d1de7fe..2a89f7c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_windows + media_kit_libs_windows_video + media_kit_video pasteboard permission_handler_windows screen_retriever_windows