fix media playback fallback and artwork hydration
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ extension WorkspaceToolDetails on WorkspaceTool {
|
||||
case WorkspaceTool.fastTransfer:
|
||||
return '秒传工具';
|
||||
case WorkspaceTool.tmdb:
|
||||
return 'TMDB 整理';
|
||||
return '媒体库管理';
|
||||
case WorkspaceTool.categories:
|
||||
return '分类管理';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user