fix media playback fallback and artwork hydration

This commit is contained in:
ngfchl
2026-07-18 17:19:09 +08:00
parent 5541e704d8
commit 605a80d633
6 changed files with 368 additions and 272 deletions
+10 -1
View File
@@ -238,7 +238,16 @@ class MediaLibraryStore {
final removedArtwork = await db.rawUpdate('''UPDATE media_items
SET poster = NULL, backdrop = NULL
WHERE poster IS NOT NULL OR backdrop IS NOT NULL''');
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
try {
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
} on DatabaseException catch (error) {
// sqflite_darwin can report the successful checkpoint result as
// "Code=0 not an error". It is not a failed database optimization.
final text = error.toString();
if (!text.contains('Code=0') || !text.contains('not an error')) {
rethrow;
}
}
await db.execute('PRAGMA optimize');
await db.execute('VACUUM');
final after = await _databaseBytes(db.path);
+6 -72
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
@@ -72,8 +74,8 @@ class MediaLibraryPage extends ConsumerStatefulWidget {
}
class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
String _tmdbApiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
bool _showApiKeyInput = false;
final String _tmdbApiKey =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
bool _tmdbSearching = false;
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
@@ -81,13 +83,11 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
_MediaWork? _detailWork;
_MediaWallFilter _wallFilter = _MediaWallFilter.all;
String? _activeCollectionKey;
final _apiKeyController = TextEditingController();
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_apiKeyController.text = _tmdbApiKey;
Future.microtask(() {
ref.read(mediaLibraryProvider.notifier).api = ref
.read(authProvider.notifier)
@@ -98,7 +98,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
@override
void dispose() {
_apiKeyController.dispose();
_searchController.dispose();
super.dispose();
}
@@ -253,7 +252,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
Expanded(
child: ShadInput(
controller: _searchController,
placeholder: const Text('搜索影视库或 TMDB…'),
placeholder: const Text('搜索影视库或匹配 TMDB…'),
leading: Icon(
Icons.search_rounded,
size: 16,
@@ -287,14 +286,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
child: const Text('导入数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _optimizeScrapedStorage,
leading: const Icon(Icons.cleaning_services_rounded, size: 16),
child: const Text('数据库瘦身'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
@@ -314,15 +305,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
),
],
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => setState(() => _showApiKeyInput = !_showApiKeyInput),
leading: Icon(
_tmdbApiKey.isEmpty ? Icons.key_off_rounded : Icons.key_rounded,
size: 16,
),
child: const Text('TMDB'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: _tmdbApiKey.isEmpty
? null
@@ -363,7 +345,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_showApiKeyInput) _buildTMDBConfig(context),
Text(
'媒体库',
style: TextStyle(
@@ -401,32 +382,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
);
}
Widget _buildTMDBConfig(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
ShadInput(
controller: _apiKeyController,
placeholder: const Text('TMDB API Key'),
obscureText: true,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ShadButton(onPressed: _saveApiKey, child: const Text('保存')),
),
],
),
);
}
Widget _emptyLibraryHint(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
@@ -492,10 +447,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
onBack: () => setState(() => _detailWork = null),
onDownload: (item) =>
ref.read(fileProvider.notifier).downloadFile(item.file),
onPlay: (item) => showShadDialog(
context: context,
builder: (_) => MediaPlayerDialog(file: item.file),
),
onPlay: (item) => unawaited(showMediaPlayerDialog(context, item.file)),
onExternalPlay: (item) => showShadDialog(
context: context,
builder: (_) => ExternalPlayerDialog(file: item.file),
@@ -903,15 +855,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
);
}
void _saveApiKey() {
final key = _apiKeyController.text.trim();
StorageManager.set(StorageKeys.tmdbApiKey, key);
setState(() {
_tmdbApiKey = key;
_showApiKeyInput = false;
});
}
Future<void> _exportScrapedData() async {
final directory = await FilePicker.getDirectoryPath(
dialogTitle: '选择刮削数据导出目录',
@@ -943,15 +886,6 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
}
}
Future<void> _optimizeScrapedStorage() async {
setState(() => _backupBusy = true);
try {
await ref.read(mediaLibraryProvider.notifier).optimizeLocalStorage();
} finally {
if (mounted) setState(() => _backupBusy = false);
}
}
Future<void> _searchTMDB(String query) async {
final text = query.trim();
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
+2 -5
View File
@@ -43,10 +43,7 @@ class _DraggedCloudFiles {
void _openCloudFile(BuildContext context, WidgetRef ref, CloudFile file) {
if (file.isVideo) {
showShadDialog<void>(
context: context,
builder: (_) => MediaPlayerDialog(file: file),
);
unawaited(showMediaPlayerDialog(context, file));
return;
}
ref.read(fileProvider.notifier).downloadFile(file);
@@ -685,7 +682,7 @@ class _MediaSidebar extends ConsumerWidget {
),
_SidebarTile(
icon: Icons.auto_fix_high_rounded,
label: 'TMDB 整',
label: '媒体库管',
selected: false,
onTap: () => onTool(WorkspaceTool.tmdb),
),
+1 -1
View File
@@ -28,7 +28,7 @@ extension WorkspaceToolDetails on WorkspaceTool {
case WorkspaceTool.fastTransfer:
return '秒传工具';
case WorkspaceTool.tmdb:
return 'TMDB 整';
return '媒体库管';
case WorkspaceTool.categories:
return '分类管理';
}
+97 -1
View File
@@ -90,6 +90,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
GuangyaAPI? _api;
final _store = MediaLibraryStore();
final _tmdbDetailsRequests = <String, Future<Map<String, dynamic>>>{};
final _artworkHydrationLibraries = <String>{};
bool _loaded = false;
bool _cancelScan = false;
@@ -116,6 +117,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
items: items,
scanLogs: logs,
);
if (selectedID != null) {
unawaited(_hydrateMissingArtwork(selectedID, items));
}
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
@@ -124,12 +128,14 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
Future<void> selectLibrary(String id) async {
final items = await _loadItems(id);
state = state.copyWith(
selectedLibraryID: id,
items: await _loadItems(id),
items: items,
searchQuery: '',
clearError: true,
);
unawaited(_hydrateMissingArtwork(id, items));
}
Future<void> createLibrary({
@@ -222,6 +228,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
items: selectedID == null ? const [] : await _loadItems(selectedID),
statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}',
);
if (selectedID != null) {
unawaited(_hydrateMissingArtwork(selectedID, state.items));
}
} catch (error) {
state = state.copyWith(errorMessage: '导入失败:$error');
} finally {
@@ -498,6 +507,93 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
return updated;
}
/// Old scrape backups stored artwork as BLOBs. The compact SQLite schema
/// deliberately drops those images, so restore the durable TMDB paths from
/// their retained IDs in the background after an import or first load.
Future<void> _hydrateMissingArtwork(
String libraryID,
List<MediaLibraryItem> items,
) async {
if (_api == null || _artworkHydrationLibraries.contains(libraryID)) return;
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
if (apiKey.trim().isEmpty) return;
final missingByTMDB = <String, List<MediaLibraryItem>>{};
for (final item in items) {
final id = item.tmdbID;
final kind = item.mediaKind;
final missingPoster = item.posterPath == null || item.posterPath!.isEmpty;
final missingBackdrop =
item.backdropPath == null || item.backdropPath!.isEmpty;
if (id == null || kind == null || (!missingPoster && !missingBackdrop)) {
continue;
}
missingByTMDB.putIfAbsent('${kind.name}:$id', () => []).add(item);
}
if (missingByTMDB.isEmpty) return;
_artworkHydrationLibraries.add(libraryID);
final proxyHost =
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
final proxyPort =
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '';
final concurrency =
(int.tryParse(
StorageManager.get<String>(
StorageKeys.mediaScanConcurrency,
) ??
'3',
) ??
3)
.clamp(1, 12);
final groups = missingByTMDB.entries.toList();
var completed = 0;
try {
for (var start = 0; start < groups.length; start += concurrency) {
final batch = groups.sublist(
start,
(start + concurrency).clamp(0, groups.length),
);
final results = await Future.wait(
batch.map((entry) async {
final prototype = entry.value.first;
try {
final details = await _tmdbDetails(
prototype.tmdbID!,
prototype.mediaKind,
apiKey: apiKey,
proxyHost: proxyHost,
proxyPort: proxyPort,
);
return entry.value
.map((item) => _itemFromTMDBDetails(item, details))
.toList();
} catch (_) {
return const <MediaLibraryItem>[];
}
}),
);
final updates = results.expand((items) => items).toList();
completed += batch.length;
if (updates.isNotEmpty) await _store.upsertItems(updates);
if (state.selectedLibraryID == libraryID) {
final updatedByID = {for (final item in updates) item.id: item};
state = state.copyWith(
items: state.items
.map((item) => updatedByID[item.id] ?? item)
.toList(),
statusMessage: completed == groups.length
? '已补齐 ${groups.length} 个影视条目的海报与横幅'
: '正在补齐影视图片 $completed/${groups.length}',
);
}
}
} finally {
_artworkHydrationLibraries.remove(libraryID);
}
}
void clearError() {
state = state.copyWith(clearError: true);
}
+252 -192
View File
@@ -13,10 +13,36 @@ 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});
const MediaPlayerDialog({
super.key,
required this.file,
this.onPlaybackFailure,
});
@override
ConsumerState<MediaPlayerDialog> createState() => _MediaPlayerDialogState();
@@ -27,10 +53,10 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
late final VideoController _controller;
StreamSubscription<VideoParams>? _videoParamsSubscription;
Timer? _videoDimensionFallbackTimer;
final _videoKey = GlobalKey<VideoState>();
late CloudFile _currentFile;
String? _error;
bool _loading = true;
bool _externalFallbackOpened = false;
var _videoAspectRatio = 3 / 2;
var _hasVideoDimensions = false;
bool _showEpisodes = false;
@@ -41,21 +67,22 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
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;
});
},
),
);
try {
_controller = VideoController(_player);
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,
);
@@ -64,11 +91,14 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
}
Future<void> _open([CloudFile? file]) async {
if (file != null && mounted) {
_videoDimensionFallbackTimer?.cancel();
if (mounted) {
setState(() {
_currentFile = file;
if (file != null) _currentFile = file;
_loading = true;
_error = null;
_hasVideoDimensions = false;
_videoAspectRatio = 3 / 2;
});
}
try {
@@ -91,13 +121,24 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
.siblingFiles(_currentFile);
_subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles);
} catch (error) {
if (mounted) {
setState(() {
_error = error.toString();
_loading = false;
});
}
} finally {}
_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) {
@@ -208,7 +249,12 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
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);
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)
@@ -219,7 +265,12 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
.toDouble();
final contentWidth = videoWidth + sideWidth;
return ShadDialog(
constraints: BoxConstraints(maxWidth: contentWidth),
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,
@@ -241,71 +292,53 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
],
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(),
),
),
],
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)),
],
),
if (_showEpisodes) ...[
const SizedBox(width: 10),
SizedBox(width: 240, child: _episodeList(cs)),
],
),
],
),
),
),
@@ -371,14 +404,14 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
class _MediaPlaybackControls extends StatefulWidget {
final Player player;
final VideoController controller;
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.controller,
required this.fullscreen,
required this.onSearchSubtitles,
required this.onLoadLocalSubtitle,
required this.onToggleFullscreen,
@@ -390,6 +423,9 @@ class _MediaPlaybackControls extends StatefulWidget {
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);
@@ -402,57 +438,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
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 '自动';
@@ -464,6 +449,125 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
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>(
@@ -575,12 +679,9 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
),
),
const SizedBox(width: 20),
_menuButton(
'${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x',
_selectRate,
),
_menuButton('音轨', _selectAudio),
_menuButton('字幕', _selectSubtitle),
_rateMenu(),
_audioMenu(),
_subtitleMenu(),
_menuButton('搜字幕', widget.onSearchSubtitles),
_menuButton('加载字幕', widget.onLoadLocalSubtitle),
_controlButton(
@@ -613,7 +714,9 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
),
),
_controlButton(
Icons.fullscreen_rounded,
widget.fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
widget.onToggleFullscreen,
),
],
@@ -660,49 +763,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
}
}
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;