Files
guangya_flutter/lib/widgets/media_player_dialog.dart
T
2026-07-18 17:25:20 +08:00

916 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io' show Platform;
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';
Future<void> showMediaPlayerDialog(BuildContext context, CloudFile file) async {
Future<void> openExternalPlayer() async {
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
await showShadDialog<void>(
context: context,
builder: (_) => ExternalPlayerDialog(file: file),
);
}
try {
await showShadDialog<void>(
context: context,
builder: (_) =>
MediaPlayerDialog(file: file, onPlaybackFailure: openExternalPlayer),
);
} catch (_) {
await openExternalPlayer();
}
}
class MediaPlayerDialog extends ConsumerStatefulWidget {
final CloudFile file;
final Future<void> Function()? onPlaybackFailure;
const MediaPlayerDialog({
super.key,
required this.file,
this.onPlaybackFailure,
});
@override
ConsumerState<MediaPlayerDialog> createState() => _MediaPlayerDialogState();
}
class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
late final Player _player;
late final VideoController _controller;
StreamSubscription<VideoParams>? _videoParamsSubscription;
Timer? _videoDimensionFallbackTimer;
late CloudFile _currentFile;
String? _error;
bool _loading = true;
bool _externalFallbackOpened = false;
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(
configuration: PlayerConfiguration(async: !Platform.isMacOS),
);
try {
_controller = VideoController(
_player,
configuration: VideoControllerConfiguration(
// The macOS OpenGL texture backend can abort libmpv while creating
// a render context. Software textures keep the embedded player
// stable; other desktop and mobile platforms retain GPU output.
enableHardwareAcceleration: !Platform.isMacOS,
hwdec: Platform.isMacOS ? 'no' : null,
),
);
unawaited(
_controller.platform.future.then<void>(
(_) {},
onError: (Object error, StackTrace stackTrace) {
_handlePlaybackFailure(
error,
error is MissingPluginException ? '内置播放器插件未加载' : '内置视频输出初始化失败',
);
},
),
);
} catch (error) {
_handlePlaybackFailure(error, '内置视频输出初始化失败');
}
_videoParamsSubscription = _player.stream.videoParams.listen(
_updateVideoDimensions,
);
_currentFile = widget.file;
Future.microtask(_open);
}
Future<void> _open([CloudFile? file]) async {
_videoDimensionFallbackTimer?.cancel();
if (mounted) {
setState(() {
if (file != null) _currentFile = file;
_loading = true;
_error = null;
_hasVideoDimensions = false;
_videoAspectRatio = 3 / 2;
});
}
try {
// NativeVideoController applies mpv output configuration asynchronously.
// Opening only after it is ready avoids racing the render context setup.
await _controller.platform.future;
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) {
_handlePlaybackFailure(error, '内置播放器打开失败');
}
}
void _handlePlaybackFailure(Object error, String message) {
if (!mounted) return;
setState(() {
_error = '$message$error';
_loading = false;
});
if (_externalFallbackOpened || widget.onPlaybackFailure == null) return;
_externalFallbackOpened = true;
unawaited(() async {
await Future<void>.delayed(Duration.zero);
if (!mounted) return;
await Navigator.of(context).maybePop();
await widget.onPlaybackFailure!();
}());
}
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;
const dialogPadding = 40.0;
final maxDialogWidth = math.max(400.0, screen.width - 32);
final maxVideoWidth = math.max(
360.0,
maxDialogWidth - sideWidth - dialogPadding,
);
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: math.min(maxDialogWidth, contentWidth + dialogPadding),
maxHeight: math.max(360, screen.height - 24),
),
padding: const EdgeInsets.all(20),
scrollable: false,
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: 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),
),
),
)
: Video(
controller: _controller,
controls: (videoState) => _MediaPlaybackControls(
player: _player,
fullscreen: videoState.isFullscreen(),
onSearchSubtitles: _searchDirectorySubtitles,
onLoadLocalSubtitle: _loadLocalSubtitle,
onToggleFullscreen: videoState.toggleFullscreen,
),
),
),
),
),
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 bool fullscreen;
final Future<void> Function() onSearchSubtitles;
final Future<void> Function() onLoadLocalSubtitle;
final Future<void> Function() onToggleFullscreen;
const _MediaPlaybackControls({
required this.player,
required this.fullscreen,
required this.onSearchSubtitles,
required this.onLoadLocalSubtitle,
required this.onToggleFullscreen,
});
@override
State<_MediaPlaybackControls> createState() => _MediaPlaybackControlsState();
}
class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
double? _scrubbingValue;
final _ratePopover = ShadPopoverController();
final _audioPopover = ShadPopoverController();
final _subtitlePopover = ShadPopoverController();
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);
}
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
void dispose() {
_ratePopover.dispose();
_audioPopover.dispose();
_subtitlePopover.dispose();
super.dispose();
}
Widget _rateMenu() {
return ShadPopover(
controller: _ratePopover,
popover: (_) => SizedBox(
width: 210,
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final value in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () async {
await widget.player.setRate(value);
_ratePopover.hide();
if (mounted) setState(() {});
},
child: Text('${value}x'),
),
],
),
),
child: _menuButton(
'${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x',
() async => _ratePopover.toggle(),
),
);
}
Widget _audioMenu() {
final tracks = widget.player.state.tracks.audio;
return ShadPopover(
controller: _audioPopover,
popover: (_) => _trackPopover<AudioTrack>(
title: '音轨',
tracks: tracks,
selectedID: widget.player.state.track.audio.id,
onSelect: (track) async {
await widget.player.setAudioTrack(track);
_audioPopover.hide();
if (mounted) setState(() {});
},
),
child: _menuButton('音轨', () async => _audioPopover.toggle()),
);
}
Widget _subtitleMenu() {
final tracks = widget.player.state.tracks.subtitle;
return ShadPopover(
controller: _subtitlePopover,
popover: (_) => _trackPopover<SubtitleTrack>(
title: '字幕',
tracks: tracks,
selectedID: widget.player.state.track.subtitle.id,
onSelect: (track) async {
await widget.player.setSubtitleTrack(track);
_subtitlePopover.hide();
if (mounted) setState(() {});
},
),
child: _menuButton('字幕', () async => _subtitlePopover.toggle()),
);
}
Widget _trackPopover<T>({
required String title,
required List<T> tracks,
required String selectedID,
required Future<void> Function(T track) onSelect,
}) {
return SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: ListView.builder(
shrinkWrap: true,
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
final selected = (track as dynamic).id == selectedID;
return ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => onSelect(track),
leading: Icon(
selected ? Icons.check_rounded : Icons.graphic_eq_rounded,
size: 15,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
_trackLabel(track),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
);
},
),
),
],
),
);
}
@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),
_rateMenu(),
_audioMenu(),
_subtitleMenu(),
_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(
widget.fullscreen
? Icons.fullscreen_exit_rounded
: 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 _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),
),
],
);
},
),
),
);
}
}