feat: add embedded and external media players

This commit is contained in:
ngfchl
2026-07-18 14:09:28 +08:00
parent 9d2ed8f2d9
commit 73262cd71c
9 changed files with 386 additions and 17 deletions
+2
View File
@@ -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();
+17 -11
View File
@@ -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<MediaLibraryPage> {
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<MediaLibraryItem> onDownload;
final ValueChanged<MediaLibraryItem> onPlay;
final ValueChanged<MediaLibraryItem> onPlayInBrowser;
final ValueChanged<MediaLibraryItem> 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),
+64 -6
View File
@@ -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<FileState> {
);
}
Future<void> 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<List<ExternalPlayer>> availableExternalPlayers() async {
if (!Platform.isMacOS) return const [];
final installed = <ExternalPlayer>[];
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<void> 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<Uri> playbackUrl(CloudFile file) => _resolveOpenUrl(file);
Future<void> _openRemoteFile(
CloudFile file, {
required String preparingMessage,
+190
View File
@@ -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<MediaPlayerDialog> createState() => _MediaPlayerDialogState();
}
class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
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<void> _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<ExternalPlayerDialog> createState() =>
_ExternalPlayerDialogState();
}
class _ExternalPlayerDialogState extends ConsumerState<ExternalPlayerDialog> {
late final Future<List<ExternalPlayer>> _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<List<ExternalPlayer>>(
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),
),
],
);
},
),
),
);
}
}
@@ -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"))
}
+96
View File
@@ -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:
+3
View File
@@ -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:
@@ -8,6 +8,8 @@
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <pasteboard/pasteboard_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
@@ -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(
+2
View File
@@ -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