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),
),
],
);
},
),
),
);
}
}