feat: add media player controls and directory episodes
This commit is contained in:
@@ -16,6 +16,9 @@ class FileMetadataCache {
|
||||
static Future<List<CloudFile>?> folderChildren(String? folderID) =>
|
||||
_store.folderChildren(folderID);
|
||||
|
||||
static Future<List<CloudFile>?> siblingFiles(String fileID) =>
|
||||
_store.siblingFiles(fileID);
|
||||
|
||||
static Future<CloudFile?> file(String fileID) => _store.cachedFile(fileID);
|
||||
|
||||
static Future<void> updateFolderChildren(
|
||||
|
||||
@@ -293,6 +293,31 @@ class MediaLibraryStore {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CloudFile>?> siblingFiles(String fileID) async {
|
||||
final rows = await (await _db).query(
|
||||
'folder_children',
|
||||
columns: const ['children_json'],
|
||||
where: 'child_ids LIKE ?',
|
||||
whereArgs: ['%"$fileID"%'],
|
||||
);
|
||||
for (final row in rows) {
|
||||
try {
|
||||
final raw = jsonDecode(row['children_json']?.toString() ?? '[]');
|
||||
if (raw is! List) continue;
|
||||
final files = raw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(value) => CloudFile.fromJson(Map<String, dynamic>.from(value)),
|
||||
)
|
||||
.toList();
|
||||
if (files.any((file) => file.id == fileID)) return files;
|
||||
} catch (_) {
|
||||
// A stale cache row should not prevent looking at the next folder.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CloudFile?> cachedFile(String fileID) async {
|
||||
final rows = await (await _db).rawQuery(
|
||||
'''SELECT d.file_json FROM file_index i
|
||||
|
||||
@@ -721,6 +721,17 @@ class FileNotifier extends StateNotifier<FileState> {
|
||||
|
||||
Future<Uri> playbackUrl(CloudFile file) => _resolveOpenUrl(file);
|
||||
|
||||
Future<List<CloudFile>> siblingFiles(CloudFile file) async {
|
||||
final siblings = await FileMetadataCache.siblingFiles(file.id);
|
||||
if (siblings == null) return [file];
|
||||
return siblings;
|
||||
}
|
||||
|
||||
Future<List<CloudFile>> siblingMediaFiles(CloudFile file) async {
|
||||
final siblings = await siblingFiles(file);
|
||||
return siblings.where((candidate) => candidate.isVideo).toList();
|
||||
}
|
||||
|
||||
Future<void> _openRemoteFile(
|
||||
CloudFile file, {
|
||||
required String preparingMessage,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.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 {
|
||||
@@ -19,23 +21,44 @@ class MediaPlayerDialog extends ConsumerStatefulWidget {
|
||||
class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
||||
late final Player _player;
|
||||
late final VideoController _controller;
|
||||
final _videoKey = GlobalKey<VideoState>();
|
||||
late CloudFile _currentFile;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
bool _showEpisodes = false;
|
||||
List<CloudFile> _episodes = const [];
|
||||
List<CloudFile> _subtitleCandidates = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_currentFile = widget.file;
|
||||
Future.microtask(_open);
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
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(widget.file);
|
||||
.playbackUrl(_currentFile);
|
||||
await _player.open(Media(url.toString()), play: true);
|
||||
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) {
|
||||
_error = error.toString();
|
||||
} finally {
|
||||
@@ -43,6 +66,85 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_player.dispose();
|
||||
@@ -54,12 +156,19 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return ShadDialog(
|
||||
title: Text(
|
||||
widget.file.name,
|
||||
_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('关闭'),
|
||||
@@ -68,25 +177,437 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
||||
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: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
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),
|
||||
),
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
'无法播放:$_error',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.destructive),
|
||||
'S${(parsed.season ?? 1).toString().padLeft(2, '0')}E${(parsed.episode ?? 0).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(fontSize: 11, color: cs.primary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Video(controller: _controller),
|
||||
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 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(
|
||||
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: 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: 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user