feat: add embedded and external media players
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user