Files
guangya_flutter/lib/widgets/media_player_dialog.dart
T

841 lines
30 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.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 '../models/media_library.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;
StreamSubscription<VideoParams>? _videoParamsSubscription;
Timer? _videoDimensionFallbackTimer;
final _videoKey = GlobalKey<VideoState>();
late CloudFile _currentFile;
String? _error;
bool _loading = true;
var _videoAspectRatio = 3 / 2;
var _hasVideoDimensions = false;
bool _showEpisodes = false;
List<CloudFile> _episodes = const [];
List<CloudFile> _subtitleCandidates = const [];
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
unawaited(
_controller.platform.future.then<void>(
(_) {},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() {
_error = error is MissingPluginException
? '内置播放器插件未加载。请完全停止当前调试进程后重新运行应用。'
: '内置视频输出初始化失败:$error';
_loading = false;
});
},
),
);
_videoParamsSubscription = _player.stream.videoParams.listen(
_updateVideoDimensions,
);
_currentFile = widget.file;
Future.microtask(_open);
}
Future<void> _open([CloudFile? file]) async {
if (file != null && mounted) {
setState(() {
_currentFile = file;
_loading = true;
_error = null;
});
}
try {
final url = await ref
.read(fileProvider.notifier)
.playbackUrl(_currentFile);
await _player.open(Media(url.toString()), play: true);
_videoDimensionFallbackTimer?.cancel();
_videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () {
if (mounted && _loading && _error == null) {
setState(() => _loading = false);
}
});
final siblings = await ref
.read(fileProvider.notifier)
.siblingMediaFiles(_currentFile);
_episodes = _matchingEpisodes(_currentFile, siblings);
final folderFiles = await ref
.read(fileProvider.notifier)
.siblingFiles(_currentFile);
_subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles);
} catch (error) {
if (mounted) {
setState(() {
_error = error.toString();
_loading = false;
});
}
} finally {}
}
void _updateVideoDimensions(VideoParams params) {
final width = params.dw ?? params.w;
final height = params.dh ?? params.h;
if (width == null || height == null || width <= 0 || height <= 0) return;
final aspect = params.aspect ?? width / height;
if (aspect < 0.25 || aspect > 4 || !mounted) return;
_videoDimensionFallbackTimer?.cancel();
setState(() {
_videoAspectRatio = aspect;
_hasVideoDimensions = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _error == null) setState(() => _loading = false);
});
}
List<CloudFile> _matchingEpisodes(CloudFile file, List<CloudFile> siblings) {
final current = ParsedMediaName.parse(file.name);
final title = _normalizedTitle(current.title);
final values = siblings.where((candidate) {
final parsed = ParsedMediaName.parse(candidate.name);
return parsed.isEpisode && _normalizedTitle(parsed.title) == title;
}).toList();
if (!values.any((candidate) => candidate.id == file.id)) values.add(file);
values.sort((a, b) {
final first = ParsedMediaName.parse(a.name);
final second = ParsedMediaName.parse(b.name);
final season = (first.season ?? 1).compareTo(second.season ?? 1);
if (season != 0) return season;
return (first.episode ?? 9999).compareTo(second.episode ?? 9999);
});
return values;
}
String _normalizedTitle(String title) =>
title.toLowerCase().replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fff]'), '');
List<CloudFile> _matchingSubtitles(CloudFile file, List<CloudFile> siblings) {
const extensions = {'srt', 'ass', 'ssa', 'sub', 'vtt', 'sup'};
final title = _normalizedTitle(ParsedMediaName.parse(file.name).title);
return siblings.where((candidate) {
final extension = candidate.name.split('.').last.toLowerCase();
if (!extensions.contains(extension)) return false;
final candidateTitle = _normalizedTitle(
ParsedMediaName.parse(candidate.name).title,
);
return candidateTitle.contains(title) || title.contains(candidateTitle);
}).toList();
}
Future<void> _searchDirectorySubtitles() async {
if (_subtitleCandidates.isEmpty) {
await showShadDialog<void>(
context: context,
builder: (context) => const ShadDialog(
title: Text('同目录字幕'),
description: Text('缓存的当前目录中没有找到匹配的字幕文件。'),
),
);
return;
}
final selected = await showShadDialog<CloudFile>(
context: context,
builder: (context) => _SubtitlePickerDialog(
title: '同目录字幕 (${_subtitleCandidates.length})',
subtitles: _subtitleCandidates,
),
);
if (selected == null || !mounted) return;
try {
final url = await ref.read(fileProvider.notifier).playbackUrl(selected);
await _player.setSubtitleTrack(
SubtitleTrack.uri(url.toString(), title: selected.name),
);
} catch (error) {
if (mounted) setState(() => _error = '加载字幕失败:$error');
}
}
Future<void> _loadLocalSubtitle() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: const ['srt', 'ass', 'ssa', 'sub', 'vtt', 'sup'],
dialogTitle: '选择外挂字幕',
);
final path = result?.files.single.path;
if (path == null) return;
await _player.setSubtitleTrack(
SubtitleTrack.uri(
Uri.file(path).toString(),
title: result!.files.single.name,
),
);
}
@override
void dispose() {
_videoDimensionFallbackTimer?.cancel();
_videoParamsSubscription?.cancel();
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final screen = MediaQuery.sizeOf(context);
final sideWidth = _showEpisodes ? 250.0 : 0.0;
final maxVideoWidth = math.max(360.0, screen.width - sideWidth - 120);
final maxVideoHeight = math.max(260.0, screen.height - 290);
final preferredWidth = _hasVideoDimensions
? math.min(900.0, _videoAspectRatio * maxVideoHeight)
: 600.0;
final videoWidth = preferredWidth.clamp(360.0, maxVideoWidth).toDouble();
final videoHeight = (videoWidth / _videoAspectRatio)
.clamp(240.0, maxVideoHeight)
.toDouble();
final contentWidth = videoWidth + sideWidth;
return ShadDialog(
constraints: BoxConstraints(maxWidth: contentWidth),
title: Text(
_currentFile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
description: const Text('内置播放器'),
actions: [
ShadButton.outline(
onPressed: _episodes.length < 2
? null
: () => setState(() => _showEpisodes = !_showEpisodes),
leading: const Icon(Icons.format_list_bulleted_rounded, size: 16),
child: const Text('同目录剧集'),
),
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
child: Material(
color: Colors.transparent,
child: AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: SizedBox(
width: contentWidth,
height: videoHeight,
child: Row(
children: [
SizedBox(
width: videoWidth,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ColoredBox(
color: Colors.black,
child: _loading
? Center(
child: CircularProgressIndicator(
strokeWidth: 3,
color: cs.primary,
),
)
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'无法播放:$_error',
textAlign: TextAlign.center,
style: TextStyle(color: cs.destructive),
),
),
)
: Stack(
children: [
Positioned.fill(
child: Video(
key: _videoKey,
controller: _controller,
controls: NoVideoControls,
),
),
Positioned.fill(
child: _MediaPlaybackControls(
player: _player,
controller: _controller,
onSearchSubtitles:
_searchDirectorySubtitles,
onLoadLocalSubtitle: _loadLocalSubtitle,
onToggleFullscreen: () =>
_videoKey.currentState
?.toggleFullscreen() ??
Future.value(),
),
),
],
),
),
),
),
if (_showEpisodes) ...[
const SizedBox(width: 10),
SizedBox(width: 240, child: _episodeList(cs)),
],
],
),
),
),
),
);
}
Widget _episodeList(ShadColorScheme cs) {
return DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: cs.border),
),
child: ListView.builder(
itemCount: _episodes.length,
itemBuilder: (context, index) {
final episode = _episodes[index];
final parsed = ParsedMediaName.parse(episode.name);
final selected = episode.id == _currentFile.id;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: selected ? null : () => _open(episode),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 9,
),
color: selected ? cs.primary.withValues(alpha: 0.10) : null,
child: Row(
children: [
SizedBox(
width: 42,
child: Text(
'S${(parsed.season ?? 1).toString().padLeft(2, '0')}E${(parsed.episode ?? 0).toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 11, color: cs.primary),
),
),
Expanded(
child: Text(
episode.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: cs.foreground,
),
),
),
],
),
),
),
);
},
),
);
}
}
class _MediaPlaybackControls extends StatefulWidget {
final Player player;
final VideoController controller;
final Future<void> Function() onSearchSubtitles;
final Future<void> Function() onLoadLocalSubtitle;
final Future<void> Function() onToggleFullscreen;
const _MediaPlaybackControls({
required this.player,
required this.controller,
required this.onSearchSubtitles,
required this.onLoadLocalSubtitle,
required this.onToggleFullscreen,
});
@override
State<_MediaPlaybackControls> createState() => _MediaPlaybackControlsState();
}
class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
double? _scrubbingValue;
Future<void> _seekBy(int seconds) async {
final position = widget.player.state.position + Duration(seconds: seconds);
final duration = widget.player.state.duration;
final target = position < Duration.zero
? Duration.zero
: (duration > Duration.zero && position > duration
? duration
: position);
await widget.player.seek(target);
}
Future<void> _selectRate() async {
final rate = await showShadDialog<double>(
context: context,
builder: (context) => ShadDialog(
title: const Text('播放速度'),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(value),
child: Text('${value}x'),
),
],
),
),
);
if (rate != null) await widget.player.setRate(rate);
}
Future<void> _selectAudio() async {
final tracks = widget.player.state.tracks.audio;
final track = await showShadDialog<AudioTrack>(
context: context,
builder: (context) => _TrackPickerDialog<AudioTrack>(
title: '选择音轨',
tracks: tracks,
selectedID: widget.player.state.track.audio.id,
label: _trackLabel,
id: (track) => track.id,
),
);
if (track != null) await widget.player.setAudioTrack(track);
}
Future<void> _selectSubtitle() async {
final tracks = widget.player.state.tracks.subtitle;
final track = await showShadDialog<SubtitleTrack>(
context: context,
builder: (context) => _TrackPickerDialog<SubtitleTrack>(
title: '选择字幕',
tracks: tracks,
selectedID: widget.player.state.track.subtitle.id,
label: _trackLabel,
id: (track) => track.id,
),
);
if (track != null) await widget.player.setSubtitleTrack(track);
}
String _trackLabel(dynamic track) {
if (track.id == 'no') return '关闭';
if (track.id == 'auto') return '自动';
final values = [
track.title,
track.language,
track.codec,
].whereType<String>().where((value) => value.isNotEmpty).toSet();
return values.isEmpty ? '轨道 ${track.id}' : values.join(' · ');
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration>(
stream: widget.player.stream.position,
initialData: widget.player.state.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final duration = widget.player.state.duration;
final max = duration.inMilliseconds.toDouble();
final current = _scrubbingValue ?? position.inMilliseconds.toDouble();
final value = max <= 0 ? 0.0 : current.clamp(0.0, max);
final cs = ShadTheme.of(context).colorScheme;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.space):
widget.player.playOrPause,
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
_seekBy(-5),
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
_seekBy(5),
const SingleActivator(LogicalKeyboardKey.keyF):
widget.onToggleFullscreen,
},
child: Focus(
autofocus: true,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTap: widget.onToggleFullscreen,
onTap: () => widget.player.playOrPause(),
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 28, 12, 10),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Color(0xE6000000)],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
activeTrackColor: cs.primary,
inactiveTrackColor: Colors.white.withValues(
alpha: 0.28,
),
thumbColor: cs.primary,
),
child: Slider(
value: value,
min: 0,
max: max <= 0 ? 1 : max,
onChangeStart: max <= 0
? null
: (next) =>
setState(() => _scrubbingValue = next),
onChanged: max <= 0
? null
: (next) =>
setState(() => _scrubbingValue = next),
onChangeEnd: max <= 0
? null
: (next) async {
await widget.player.seek(
Duration(milliseconds: next.round()),
);
if (mounted) {
setState(() => _scrubbingValue = null);
}
},
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_controlButton(
widget.player.state.playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
widget.player.playOrPause,
),
_controlButton(
Icons.replay_10_rounded,
() => _seekBy(-10),
),
_controlButton(
Icons.forward_10_rounded,
() => _seekBy(10),
),
const SizedBox(width: 6),
Text(
'${_formatTime(position)} / ${_formatTime(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
const SizedBox(width: 20),
_menuButton(
'${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x',
_selectRate,
),
_menuButton('音轨', _selectAudio),
_menuButton('字幕', _selectSubtitle),
_menuButton('搜字幕', widget.onSearchSubtitles),
_menuButton('加载字幕', widget.onLoadLocalSubtitle),
_controlButton(
widget.player.state.volume <= 0
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
() => widget.player.setVolume(
widget.player.state.volume <= 0 ? 100 : 0,
),
),
SizedBox(
width: 90,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white
.withValues(alpha: 0.28),
thumbColor: Colors.white,
),
child: Slider(
value: widget.player.state.volume.clamp(
0.0,
100.0,
),
min: 0,
max: 100,
onChanged: widget.player.setVolume,
),
),
),
_controlButton(
Icons.fullscreen_rounded,
widget.onToggleFullscreen,
),
],
),
),
],
),
),
),
],
),
),
),
);
},
);
}
Widget _controlButton(IconData icon, Future<void> Function() onPressed) {
return IconButton(
tooltip: '',
onPressed: onPressed,
icon: Icon(icon, color: Colors.white, size: 23),
);
}
Widget _menuButton(String label, Future<void> Function() onPressed) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
minimumSize: const Size(44, 36),
padding: const EdgeInsets.symmetric(horizontal: 6),
),
child: Text(label, style: const TextStyle(fontSize: 12)),
);
}
String _formatTime(Duration value) {
final hours = value.inHours;
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return hours > 0 ? '$hours:$minutes:$seconds' : '$minutes:$seconds';
}
}
class _TrackPickerDialog<T> extends StatelessWidget {
final String title;
final List<T> tracks;
final String selectedID;
final String Function(T track) label;
final String Function(T track) id;
const _TrackPickerDialog({
required this.title,
required this.tracks,
required this.selectedID,
required this.label,
required this.id,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadDialog(
title: Text(title),
child: Material(
color: Colors.transparent,
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final track in tracks)
ListTile(
onTap: () => Navigator.of(context).pop(track),
title: Text(label(track)),
trailing: id(track) == selectedID
? Icon(Icons.check_rounded, color: cs.primary)
: null,
),
],
),
),
),
);
}
}
class _SubtitlePickerDialog extends StatelessWidget {
final String title;
final List<CloudFile> subtitles;
const _SubtitlePickerDialog({required this.title, required this.subtitles});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadDialog(
title: Text(title),
description: const Text('从当前视频同目录的缓存文件中匹配'),
child: Material(
color: Colors.transparent,
child: SizedBox(
width: 520,
height: 360,
child: ListView.separated(
itemCount: subtitles.length,
separatorBuilder: (_, _) => Divider(height: 1, color: cs.border),
itemBuilder: (context, index) {
final subtitle = subtitles[index];
return ListTile(
onTap: () => Navigator.of(context).pop(subtitle),
leading: Icon(Icons.closed_caption_rounded, color: cs.primary),
title: Text(
subtitle.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(subtitle.formattedSize),
);
},
),
),
),
);
}
}
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),
),
],
);
},
),
),
);
}
}