2981 lines
101 KiB
Dart
2981 lines
101 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
|
||
import '../core/storage/storage_manager.dart';
|
||
import '../models/cloud_file.dart';
|
||
import '../models/media_library.dart';
|
||
import '../providers/auth_provider.dart';
|
||
import '../providers/file_provider.dart';
|
||
import '../providers/media_library_provider.dart';
|
||
import '../widgets/media_player_dialog.dart';
|
||
|
||
enum _MediaWallFilter { all, movies, series, collections, unmatched }
|
||
|
||
String _tmdbImageURL(String path, {required String size}) {
|
||
final source = _tmdbDirectImageURL(path, size: size);
|
||
final configured = StorageManager.get<String>(
|
||
StorageKeys.tmdbImageProxy,
|
||
)?.trim();
|
||
if (configured != null && configured.isEmpty) return source;
|
||
final proxy = Uri.tryParse(configured ?? 'https://wsrv.nl');
|
||
if (proxy == null || !proxy.hasScheme || proxy.host.isEmpty) return source;
|
||
return proxy
|
||
.replace(
|
||
path: proxy.path.isEmpty ? '/' : proxy.path,
|
||
queryParameters: {'url': source, 'output': 'webp', 'q': '85'},
|
||
)
|
||
.toString();
|
||
}
|
||
|
||
String _tmdbDirectImageURL(String path, {required String size}) {
|
||
return path.startsWith('http')
|
||
? path
|
||
: 'https://image.tmdb.org/t/p/$size$path';
|
||
}
|
||
|
||
String? _parentDirectoryName(String cloudPath) {
|
||
final segments = cloudPath
|
||
.split(RegExp(r'[/\\]+'))
|
||
.where((segment) => segment.isNotEmpty)
|
||
.toList();
|
||
return segments.length < 2 ? null : segments[segments.length - 2];
|
||
}
|
||
|
||
Widget _tmdbDirectFallback({
|
||
required String path,
|
||
required String size,
|
||
required Widget fallback,
|
||
BoxFit fit = BoxFit.cover,
|
||
double? width,
|
||
double? height,
|
||
}) {
|
||
return CachedNetworkImage(
|
||
imageUrl: _tmdbDirectImageURL(path, size: size),
|
||
fit: fit,
|
||
width: width,
|
||
height: height,
|
||
errorWidget: (_, _, _) => fallback,
|
||
);
|
||
}
|
||
|
||
class MediaLibraryPage extends ConsumerStatefulWidget {
|
||
final bool showLibrarySidebar;
|
||
final String? searchTitle;
|
||
|
||
const MediaLibraryPage({
|
||
super.key,
|
||
this.showLibrarySidebar = true,
|
||
this.searchTitle,
|
||
});
|
||
|
||
static void showCreateDialog(BuildContext context, WidgetRef ref) {
|
||
_MediaLibraryPageState._showCreateLibraryDialog(context, ref);
|
||
}
|
||
|
||
@override
|
||
ConsumerState<MediaLibraryPage> createState() => _MediaLibraryPageState();
|
||
}
|
||
|
||
class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||
final String _tmdbApiKey =
|
||
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
|
||
bool _tmdbSearching = false;
|
||
String? _tmdbError;
|
||
List<Map<String, dynamic>> _tmdbResults = [];
|
||
bool _backupBusy = false;
|
||
_MediaWork? _detailWork;
|
||
_MediaWallFilter _wallFilter = _MediaWallFilter.all;
|
||
String? _activeCollectionKey;
|
||
final _searchController = TextEditingController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Future.microtask(() {
|
||
ref.read(mediaLibraryProvider.notifier).api = ref
|
||
.read(authProvider.notifier)
|
||
.api;
|
||
ref.read(mediaLibraryProvider.notifier).load();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(mediaLibraryProvider);
|
||
ref.listen<MediaLibraryState>(mediaLibraryProvider, (previous, next) {
|
||
final message = next.errorMessage ?? next.statusMessage;
|
||
final previousMessage = previous?.errorMessage ?? previous?.statusMessage;
|
||
final isProgressMessage =
|
||
next.errorMessage == null && message?.startsWith('正在') == true;
|
||
if (message == null ||
|
||
message.isEmpty ||
|
||
message == previousMessage ||
|
||
isProgressMessage) {
|
||
return;
|
||
}
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
ShadSonner.maybeOf(context)?.show(
|
||
next.errorMessage == null
|
||
? ShadToast(
|
||
title: const Text('媒体库'),
|
||
description: Text(message),
|
||
showCloseIconOnlyWhenHovered: false,
|
||
)
|
||
: ShadToast.destructive(
|
||
title: const Text('媒体库操作失败'),
|
||
description: Text(message),
|
||
showCloseIconOnlyWhenHovered: false,
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
||
child: Column(
|
||
children: [
|
||
_buildHeader(context, state),
|
||
const SizedBox(height: 12),
|
||
_buildToolbar(context, state),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: widget.showLibrarySidebar
|
||
? Row(
|
||
children: [
|
||
_buildLibraryList(context, state),
|
||
VerticalDivider(
|
||
width: 24,
|
||
color: ShadTheme.of(context).colorScheme.border,
|
||
),
|
||
Expanded(child: _buildMainPanel(context, state)),
|
||
],
|
||
)
|
||
: _buildMainPanel(context, state),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader(BuildContext context, MediaLibraryState state) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final stats = state.statistics;
|
||
return Row(
|
||
children: [
|
||
Icon(Icons.movie_filter_rounded, size: 26, color: cs.primary),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.searchTitle ?? '光鸭影视',
|
||
style: TextStyle(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
Text(
|
||
state.selectedLibrary?.name ?? '创建媒体库后扫描云盘影视文件',
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_statPill(
|
||
context,
|
||
'全部',
|
||
stats.total.toString(),
|
||
selected: _wallFilter == _MediaWallFilter.all,
|
||
onTap: () => _selectWallFilter(_MediaWallFilter.all),
|
||
),
|
||
_statPill(
|
||
context,
|
||
'电影',
|
||
stats.movies.toString(),
|
||
selected: _wallFilter == _MediaWallFilter.movies,
|
||
onTap: () => _selectWallFilter(_MediaWallFilter.movies),
|
||
),
|
||
_statPill(
|
||
context,
|
||
'剧集',
|
||
stats.series.toString(),
|
||
selected: _wallFilter == _MediaWallFilter.series,
|
||
onTap: () => _selectWallFilter(_MediaWallFilter.series),
|
||
),
|
||
_statPill(
|
||
context,
|
||
'合集',
|
||
stats.collections.toString(),
|
||
selected: _wallFilter == _MediaWallFilter.collections,
|
||
onTap: () => _selectWallFilter(_MediaWallFilter.collections),
|
||
),
|
||
_statPill(
|
||
context,
|
||
'待匹配',
|
||
stats.unmatched.toString(),
|
||
selected: _wallFilter == _MediaWallFilter.unmatched,
|
||
onTap: () => _selectWallFilter(_MediaWallFilter.unmatched),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
void _selectWallFilter(_MediaWallFilter filter) {
|
||
setState(() {
|
||
_wallFilter = filter;
|
||
_activeCollectionKey = null;
|
||
_detailWork = null;
|
||
});
|
||
}
|
||
|
||
Widget _statPill(
|
||
BuildContext context,
|
||
String label,
|
||
String value, {
|
||
required bool selected,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 8),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: selected ? cs.primary.withValues(alpha: 0.12) : cs.muted,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: selected ? cs.primary : cs.border),
|
||
),
|
||
child: Text(
|
||
'$label $value',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: selected ? cs.primary : cs.mutedForeground,
|
||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: ShadInput(
|
||
controller: _searchController,
|
||
placeholder: const Text('搜索影视库或匹配 TMDB…'),
|
||
leading: Icon(
|
||
Icons.search_rounded,
|
||
size: 16,
|
||
color: cs.mutedForeground,
|
||
),
|
||
onChanged: (value) =>
|
||
ref.read(mediaLibraryProvider.notifier).setSearchQuery(value),
|
||
onSubmitted: _searchTMDB,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.outline(
|
||
onPressed: () => _showCreateLibraryDialog(context, ref),
|
||
leading: const Icon(Icons.add_rounded, size: 16),
|
||
child: const Text('媒体库'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.outline(
|
||
onPressed: _backupBusy || state.isScanning
|
||
? null
|
||
: _exportScrapedData,
|
||
leading: const Icon(Icons.save_alt_rounded, size: 16),
|
||
child: const Text('导出数据'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.outline(
|
||
onPressed: _backupBusy || state.isScanning
|
||
? null
|
||
: _importScrapedData,
|
||
leading: const Icon(Icons.upload_file_rounded, size: 16),
|
||
child: const Text('导入数据'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.outline(
|
||
onPressed: state.selectedLibrary == null || state.isScanning
|
||
? null
|
||
: () => ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.rescanSelectedLibrary(),
|
||
leading: const Icon(Icons.refresh_rounded, size: 16),
|
||
child: const Text('重新扫描'),
|
||
),
|
||
if (state.isScanning) ...[
|
||
const SizedBox(width: 8),
|
||
ShadButton.destructive(
|
||
onPressed: () =>
|
||
ref.read(mediaLibraryProvider.notifier).cancelScan(),
|
||
leading: const Icon(Icons.stop_rounded, size: 16),
|
||
child: const Text('停止'),
|
||
),
|
||
],
|
||
const SizedBox(width: 8),
|
||
ShadButton(
|
||
onPressed: _tmdbApiKey.isEmpty
|
||
? null
|
||
: () => _searchTMDB(_searchController.text),
|
||
leading: const Icon(Icons.travel_explore_rounded, size: 16),
|
||
child: const Text('匹配'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildLibraryList(BuildContext context, MediaLibraryState state) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return SizedBox(
|
||
width: 260,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'媒体库',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: state.libraries.isEmpty
|
||
? _emptyLibraryHint(context)
|
||
: ListView.builder(
|
||
itemCount: state.libraries.length,
|
||
itemBuilder: (context, index) {
|
||
final library = state.libraries[index];
|
||
final selected = library.id == state.selectedLibrary?.id;
|
||
return _LibraryRow(
|
||
library: library,
|
||
selected: selected,
|
||
onTap: () => ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.selectLibrary(library.id),
|
||
onEdit: () =>
|
||
_showEditLibraryDialog(context, ref, library),
|
||
onDelete: () => ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.deleteLibrary(library.id),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _emptyLibraryHint(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.video_library_outlined,
|
||
size: 42,
|
||
color: cs.mutedForeground,
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'暂无媒体库',
|
||
style: TextStyle(fontSize: 14, color: cs.mutedForeground),
|
||
),
|
||
const SizedBox(height: 10),
|
||
ShadButton.outline(
|
||
onPressed: () => _showCreateLibraryDialog(context, ref),
|
||
child: const Text('创建媒体库'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMainPanel(BuildContext context, MediaLibraryState state) {
|
||
if (state.isLoading) {
|
||
return const Center(child: ShadProgress());
|
||
}
|
||
if (_tmdbSearching || _tmdbResults.isNotEmpty || _tmdbError != null) {
|
||
return _tmdbResultPanel(context);
|
||
}
|
||
final visibleItems = state.visibleItems;
|
||
final collections = _MediaCollection.fromItems(visibleItems);
|
||
final activeCollection = collections
|
||
.where((collection) => collection.key == _activeCollectionKey)
|
||
.firstOrNull;
|
||
final filteredItems = switch (_wallFilter) {
|
||
_MediaWallFilter.all => visibleItems,
|
||
_MediaWallFilter.movies =>
|
||
visibleItems
|
||
.where((item) => item.mediaKind == TMDBMediaKind.movie)
|
||
.toList(),
|
||
_MediaWallFilter.series =>
|
||
visibleItems
|
||
.where((item) => item.mediaKind == TMDBMediaKind.tv)
|
||
.toList(),
|
||
_MediaWallFilter.collections => activeCollection?.resources ?? const [],
|
||
_MediaWallFilter.unmatched =>
|
||
visibleItems.where((item) => !item.isMatched).toList(),
|
||
};
|
||
final works = _MediaWork.fromItems(filteredItems);
|
||
if (state.selectedLibrary == null) {
|
||
return _mainEmpty(context, '还没有媒体库', '从云盘根目录或当前目录创建一个媒体库');
|
||
}
|
||
if (_detailWork != null) {
|
||
final detailResourceIDs = _detailWork!.resources
|
||
.map((resource) => resource.id)
|
||
.toSet();
|
||
final current = works
|
||
.where(
|
||
(work) =>
|
||
work.key == _detailWork!.key ||
|
||
work.resources.any(
|
||
(resource) => detailResourceIDs.contains(resource.id),
|
||
),
|
||
)
|
||
.firstOrNull;
|
||
return _MediaDetailPanel(
|
||
work: current ?? _detailWork!,
|
||
onBack: () => setState(() => _detailWork = null),
|
||
onDownload: (item) =>
|
||
ref.read(fileProvider.notifier).downloadFile(item.file),
|
||
onPlay: (item) => unawaited(
|
||
showMediaPlayerDialog(
|
||
context,
|
||
item.file,
|
||
episodeCandidates: (current ?? _detailWork!).resources
|
||
.map((resource) => resource.file)
|
||
.toList(),
|
||
),
|
||
),
|
||
onExternalPlay: (item) => showShadDialog(
|
||
context: context,
|
||
builder: (_) => ExternalPlayerDialog(file: item.file),
|
||
),
|
||
onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!),
|
||
onCancelRefresh: () =>
|
||
ref.read(mediaLibraryProvider.notifier).cancelDetailSync(),
|
||
onRefreshAndRecognize: () async {
|
||
final selected = current ?? _detailWork!;
|
||
final pendingMatches = await ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.refreshAndRecognizeItems(selected.resources);
|
||
for (final request in pendingMatches) {
|
||
if (!context.mounted) return;
|
||
final candidate = await showShadDialog<Map<String, dynamic>>(
|
||
context: context,
|
||
builder: (_) => _ManualTMDBMatchDialog(
|
||
initialQuery: request.items.first.title,
|
||
initialResults: request.candidates,
|
||
),
|
||
);
|
||
if (candidate == null) continue;
|
||
final notifier = ref.read(mediaLibraryProvider.notifier);
|
||
for (final resource in request.items) {
|
||
await notifier.applyTMDBMatch(resource, candidate);
|
||
}
|
||
}
|
||
if (!context.mounted || _detailWork == null) return;
|
||
final updatedWorks = _MediaWork.fromItems(
|
||
ref.read(mediaLibraryProvider).items,
|
||
);
|
||
final selectedIDs = selected.resources
|
||
.map((resource) => resource.id)
|
||
.toSet();
|
||
final refreshed = updatedWorks
|
||
.where(
|
||
(work) => work.resources.any(
|
||
(resource) => selectedIDs.contains(resource.id),
|
||
),
|
||
)
|
||
.firstOrNull;
|
||
if (refreshed != null) setState(() => _detailWork = refreshed);
|
||
},
|
||
);
|
||
}
|
||
final showingCollectionOverview =
|
||
_wallFilter == _MediaWallFilter.collections && activeCollection == null;
|
||
final wallContent = showingCollectionOverview
|
||
? _collectionOverview(context, collections)
|
||
: works.isEmpty
|
||
? _mainEmpty(
|
||
context,
|
||
state.isScanning
|
||
? '正在扫描媒体库'
|
||
: (_wallFilter == _MediaWallFilter.all ? '没有扫描结果' : '当前筛选没有结果'),
|
||
state.isScanning ? '发现并入库的资源会立即显示在这里' : '点击扫描读取该媒体库下的视频文件',
|
||
)
|
||
: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final columns = (constraints.maxWidth / 154).floor().clamp(2, 7);
|
||
return GridView.builder(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: columns,
|
||
mainAxisSpacing: 18,
|
||
crossAxisSpacing: 14,
|
||
childAspectRatio: 0.52,
|
||
),
|
||
itemCount: works.length,
|
||
itemBuilder: (context, index) => _MediaPosterTile(
|
||
work: works[index],
|
||
onOpen: () => setState(() => _detailWork = works[index]),
|
||
onDownload: () => ref
|
||
.read(fileProvider.notifier)
|
||
.downloadFile(works[index].primary.file),
|
||
onRescan: state.isScanning
|
||
? null
|
||
: () => ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.rescanSelectedLibrary(),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
final content = activeCollection == null
|
||
? wallContent
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: () => setState(() => _activeCollectionKey = null),
|
||
leading: const Icon(Icons.arrow_back_rounded, size: 16),
|
||
child: Text('返回合集:${activeCollection.name}'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(child: wallContent),
|
||
],
|
||
);
|
||
if (!state.isScanning) {
|
||
if (state.scanLogs.isEmpty) return content;
|
||
return Column(
|
||
children: [
|
||
_recentScanLogs(context, state),
|
||
const SizedBox(height: 10),
|
||
Expanded(child: content),
|
||
],
|
||
);
|
||
}
|
||
return Column(
|
||
children: [
|
||
_scanProgress(context, state),
|
||
const SizedBox(height: 10),
|
||
Expanded(child: content),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _collectionOverview(
|
||
BuildContext context,
|
||
List<_MediaCollection> collections,
|
||
) {
|
||
if (collections.isEmpty) {
|
||
return _mainEmpty(context, '没有自动合集', '已匹配合集信息的电影会显示在这里');
|
||
}
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final columns = (constraints.maxWidth / 154).floor().clamp(2, 7);
|
||
return GridView.builder(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: columns,
|
||
mainAxisSpacing: 18,
|
||
crossAxisSpacing: 14,
|
||
childAspectRatio: 0.52,
|
||
),
|
||
itemCount: collections.length,
|
||
itemBuilder: (context, index) => _MediaCollectionTile(
|
||
collection: collections[index],
|
||
onOpen: () => setState(() {
|
||
_activeCollectionKey = collections[index].key;
|
||
_detailWork = null;
|
||
}),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _scanProgress(BuildContext context, MediaLibraryState state) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Semantics(
|
||
label: '媒体扫描进度:${state.progress.phase},已处理 ${state.progress.completed} 项',
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: cs.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const SizedBox(width: 120, child: ShadProgress()),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
state.progress.phase,
|
||
style: TextStyle(color: cs.foreground),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'已发现 ${state.progress.completed} 个视频文件,已入库资源会实时显示。',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (state.scanLogs.isNotEmpty) ...[
|
||
const SizedBox(height: 10),
|
||
Divider(height: 1, color: cs.border),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'任务日志',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SizedBox(
|
||
height: 84,
|
||
child: ListView.builder(
|
||
reverse: true,
|
||
itemCount: state.scanLogs.length,
|
||
itemBuilder: (context, index) {
|
||
final log = state.scanLogs.reversed.elementAt(index);
|
||
final time = TimeOfDay.fromDateTime(
|
||
log.createdAt,
|
||
).format(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Text(
|
||
'$time ${log.message}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: log.isError
|
||
? cs.destructive
|
||
: cs.mutedForeground,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _recentScanLogs(BuildContext context, MediaLibraryState state) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted.withValues(alpha: 0.68),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: cs.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'最近扫描日志',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SizedBox(
|
||
height: 54,
|
||
child: ListView.builder(
|
||
reverse: true,
|
||
itemCount: state.scanLogs.length,
|
||
itemBuilder: (context, index) {
|
||
final log = state.scanLogs.reversed.elementAt(index);
|
||
final time = TimeOfDay.fromDateTime(
|
||
log.createdAt,
|
||
).format(context);
|
||
return Text(
|
||
'$time ${log.message}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: log.isError ? cs.destructive : cs.mutedForeground,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _mainEmpty(BuildContext context, String title, String subtitle) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.movie_creation_outlined,
|
||
size: 56,
|
||
color: cs.mutedForeground,
|
||
),
|
||
const SizedBox(height: 14),
|
||
Text(title, style: TextStyle(fontSize: 16, color: cs.foreground)),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
subtitle,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _tmdbResultPanel(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
if (_tmdbSearching) return const Center(child: ShadProgress());
|
||
if (_tmdbError != null) {
|
||
return _mainEmpty(context, 'TMDB 请求失败', _tmdbError!);
|
||
}
|
||
if (_tmdbResults.isEmpty) {
|
||
return _mainEmpty(context, '没有 TMDB 结果', '换一个片名继续搜索');
|
||
}
|
||
return ListView.separated(
|
||
itemCount: _tmdbResults.length,
|
||
separatorBuilder: (context, index) =>
|
||
Divider(color: cs.border, height: 1),
|
||
itemBuilder: (context, index) =>
|
||
_buildTMDBResultItem(context, _tmdbResults[index]),
|
||
);
|
||
}
|
||
|
||
Widget _buildTMDBResultItem(BuildContext context, Map<String, dynamic> item) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final title = item['title'] ?? item['name'] ?? '未知';
|
||
final overview = item['overview']?.toString() ?? '';
|
||
final releaseDate =
|
||
item['release_date']?.toString() ??
|
||
item['first_air_date']?.toString() ??
|
||
'';
|
||
final mediaType = item['media_type']?.toString() ?? 'movie';
|
||
final posterPath = item['poster_path'] as String?;
|
||
final year = releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: posterPath == null
|
||
? _posterPlaceholder(context, 74, 110)
|
||
: CachedNetworkImage(
|
||
imageUrl: _tmdbImageURL(posterPath, size: 'w200'),
|
||
width: 74,
|
||
height: 110,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (context, url, error) => _tmdbDirectFallback(
|
||
path: posterPath,
|
||
size: 'w200',
|
||
width: 74,
|
||
height: 110,
|
||
fallback: _posterPlaceholder(context, 74, 110),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
year.isEmpty ? title.toString() : '$title ($year)',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
),
|
||
ShadBadge(child: Text(mediaType == 'tv' ? '剧集' : '电影')),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
overview.isEmpty ? '暂无简介' : overview,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
maxLines: 4,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _posterPlaceholder(BuildContext context, double width, double height) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
width: width,
|
||
height: height,
|
||
color: cs.muted,
|
||
child: Icon(Icons.movie_rounded, color: cs.mutedForeground),
|
||
);
|
||
}
|
||
|
||
static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) {
|
||
final fileState = ref.read(fileProvider);
|
||
final currentPath = fileState.folderPath.isEmpty
|
||
? '云盘根目录'
|
||
: fileState.folderPath.map((file) => file.name).join(' / ');
|
||
showShadDialog(
|
||
context: context,
|
||
builder: (ctx) => _CreateMediaLibraryDialog(
|
||
initialRootID: fileState.folderPath.isEmpty
|
||
? null
|
||
: fileState.folderPath.last.id,
|
||
initialPath: currentPath,
|
||
initialName: fileState.folderPath.isEmpty
|
||
? '我的影视库'
|
||
: fileState.folderPath.last.name,
|
||
),
|
||
);
|
||
}
|
||
|
||
static void _showEditLibraryDialog(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
MediaLibraryDefinition library,
|
||
) {
|
||
showShadDialog(
|
||
context: context,
|
||
builder: (_) => _CreateMediaLibraryDialog(
|
||
initialRootID: library.rootID,
|
||
initialPath: library.rootPath,
|
||
initialName: library.name,
|
||
editingLibrary: library,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _exportScrapedData() async {
|
||
final directory = await FilePicker.getDirectoryPath(
|
||
dialogTitle: '选择刮削数据导出目录',
|
||
);
|
||
if (directory == null || !mounted) return;
|
||
setState(() => _backupBusy = true);
|
||
try {
|
||
await ref
|
||
.read(mediaLibraryProvider.notifier)
|
||
.exportScrapedData('$directory/media-library.sqlite3');
|
||
} finally {
|
||
if (mounted) setState(() => _backupBusy = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _importScrapedData() async {
|
||
final backup = await FilePicker.pickFile(
|
||
dialogTitle: '导入影视缓存与刮削数据',
|
||
type: FileType.custom,
|
||
allowedExtensions: const ['sqlite3', 'sqlite', 'db'],
|
||
);
|
||
final path = backup?.path;
|
||
if (path == null || !mounted) return;
|
||
setState(() => _backupBusy = true);
|
||
try {
|
||
await ref.read(mediaLibraryProvider.notifier).importScrapedData(path);
|
||
} finally {
|
||
if (mounted) setState(() => _backupBusy = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _searchTMDB(String query) async {
|
||
final text = query.trim();
|
||
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
|
||
|
||
setState(() {
|
||
_tmdbSearching = true;
|
||
_tmdbError = null;
|
||
_tmdbResults = [];
|
||
});
|
||
|
||
try {
|
||
final api = ref.read(authProvider.notifier).api;
|
||
final result = await api.tmdbSearch(
|
||
text,
|
||
apiKey: _tmdbApiKey,
|
||
proxyHost: StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
|
||
proxyPort: StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
|
||
);
|
||
final results =
|
||
(result['results'] as List?)
|
||
?.whereType<Map>()
|
||
.where(
|
||
(item) =>
|
||
item['media_type'] == 'movie' || item['media_type'] == 'tv',
|
||
)
|
||
.map((item) => Map<String, dynamic>.from(item))
|
||
.toList() ??
|
||
[];
|
||
setState(() {
|
||
_tmdbResults = results;
|
||
_tmdbSearching = false;
|
||
});
|
||
} catch (e) {
|
||
setState(() {
|
||
_tmdbError = e.toString();
|
||
_tmdbSearching = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _showManualTMDBMatch(_MediaWork work) async {
|
||
final candidate = await showShadDialog<Map<String, dynamic>>(
|
||
context: context,
|
||
builder: (_) => _ManualTMDBMatchDialog(initialQuery: work.primary.title),
|
||
);
|
||
if (candidate == null || !mounted) return;
|
||
final notifier = ref.read(mediaLibraryProvider.notifier);
|
||
final updated = <MediaLibraryItem>[];
|
||
for (final resource in work.resources) {
|
||
updated.add(await notifier.applyTMDBMatch(resource, candidate));
|
||
}
|
||
if (mounted) {
|
||
setState(() {
|
||
_detailWork = _MediaWork.fromItems(updated).first;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
class _CreateMediaLibraryDialog extends ConsumerStatefulWidget {
|
||
final String? initialRootID;
|
||
final String initialPath;
|
||
final String initialName;
|
||
final MediaLibraryDefinition? editingLibrary;
|
||
|
||
const _CreateMediaLibraryDialog({
|
||
required this.initialRootID,
|
||
required this.initialPath,
|
||
required this.initialName,
|
||
this.editingLibrary,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_CreateMediaLibraryDialog> createState() =>
|
||
_CreateMediaLibraryDialogState();
|
||
}
|
||
|
||
class _CreateMediaLibraryDialogState
|
||
extends ConsumerState<_CreateMediaLibraryDialog> {
|
||
late final TextEditingController _nameController;
|
||
final _minSizeController = TextEditingController(text: '50');
|
||
MediaLibraryKind _kind = MediaLibraryKind.mixed;
|
||
bool _recursive = true;
|
||
bool _isBrowsing = false;
|
||
bool _isLoadingFolders = false;
|
||
String? _folderError;
|
||
late List<MediaLibrarySource> _sources;
|
||
final _browserPath = <CloudFile>[];
|
||
var _folders = <CloudFile>[];
|
||
|
||
bool get _isEditing => widget.editingLibrary != null;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_sources =
|
||
widget.editingLibrary?.sources ??
|
||
[
|
||
MediaLibrarySource(
|
||
id: 'initial-source',
|
||
rootID: widget.initialRootID,
|
||
path: widget.initialPath,
|
||
),
|
||
];
|
||
_nameController = TextEditingController(text: widget.initialName);
|
||
if (_isEditing) {
|
||
_kind = widget.editingLibrary!.kind;
|
||
_recursive = widget.editingLibrary!.recursive;
|
||
_minSizeController.text = widget.editingLibrary!.minimumSizeMB.toString();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
_minSizeController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String get _browserLocation => _browserPath.isEmpty
|
||
? '云盘根目录'
|
||
: _browserPath.map((folder) => folder.name).join(' / ');
|
||
|
||
String? get _browserFolderID =>
|
||
_browserPath.isEmpty ? null : _browserPath.last.id;
|
||
|
||
Future<void> _startBrowsing() async {
|
||
setState(() {
|
||
_isBrowsing = true;
|
||
_browserPath.clear();
|
||
_folders = [];
|
||
});
|
||
await _loadFolders();
|
||
}
|
||
|
||
Future<void> _loadFolders() async {
|
||
setState(() {
|
||
_isLoadingFolders = true;
|
||
_folderError = null;
|
||
});
|
||
try {
|
||
final response = await ref
|
||
.read(authProvider.notifier)
|
||
.api
|
||
.fsFiles(parentID: _browserFolderID, pageSize: 1000);
|
||
if (mounted) {
|
||
setState(() {
|
||
_folders =
|
||
_extractFiles(response).where((file) => file.isDirectory).toList()
|
||
..sort(
|
||
(a, b) =>
|
||
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||
);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (mounted) setState(() => _folderError = error.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _isLoadingFolders = false);
|
||
}
|
||
}
|
||
|
||
List<CloudFile> _extractFiles(Map<String, dynamic> value) {
|
||
final files = <CloudFile>[];
|
||
final ids = <String>{};
|
||
void visit(dynamic node) {
|
||
if (node is Map) {
|
||
try {
|
||
final file = CloudFile.fromJson(Map<String, dynamic>.from(node));
|
||
if (ids.add(file.id)) files.add(file);
|
||
} catch (_) {}
|
||
for (final child in node.values) {
|
||
visit(child);
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(value);
|
||
return files;
|
||
}
|
||
|
||
void _useBrowserFolder() {
|
||
final rootID = _browserFolderID;
|
||
final rootPath = _browserLocation;
|
||
setState(() {
|
||
if (rootID == null) {
|
||
_sources = [
|
||
MediaLibrarySource(id: 'root-source', rootID: null, path: rootPath),
|
||
];
|
||
} else if (!_sources.any((source) => source.rootID == rootID)) {
|
||
_sources = [
|
||
..._sources.where((source) => source.rootID != null),
|
||
MediaLibrarySource(
|
||
id: 'source-${DateTime.now().microsecondsSinceEpoch}',
|
||
rootID: rootID,
|
||
path: rootPath,
|
||
),
|
||
];
|
||
}
|
||
if (_nameController.text.trim().isEmpty ||
|
||
_nameController.text == widget.initialName) {
|
||
_nameController.text = _browserPath.isEmpty
|
||
? '我的影视库'
|
||
: _browserPath.last.name;
|
||
}
|
||
_isBrowsing = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _save(BuildContext context) async {
|
||
final notifier = ref.read(mediaLibraryProvider.notifier);
|
||
if (_isEditing) {
|
||
await notifier.updateLibrary(
|
||
widget.editingLibrary!.copyWith(
|
||
name: _nameController.text,
|
||
sources: _sources,
|
||
kind: _kind,
|
||
recursive: _recursive,
|
||
minimumSizeMB: int.tryParse(_minSizeController.text.trim()) ?? 50,
|
||
updatedAt: DateTime.now(),
|
||
),
|
||
);
|
||
} else {
|
||
await notifier.createLibrary(
|
||
name: _nameController.text,
|
||
rootID: _sources.isEmpty ? null : _sources.first.rootID,
|
||
rootPath: _sources.isEmpty ? '未配置目录' : _sources.first.path,
|
||
sources: _sources,
|
||
kind: _kind,
|
||
recursive: _recursive,
|
||
minimumSizeMB: int.tryParse(_minSizeController.text.trim()) ?? 50,
|
||
);
|
||
}
|
||
if (context.mounted) Navigator.of(context).pop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ShadDialog(
|
||
title: Text(_isBrowsing ? '选择云盘文件夹' : (_isEditing ? '管理媒体库' : '创建媒体库')),
|
||
description: Text(
|
||
_isBrowsing ? '进入目标目录后,选择该目录作为媒体库来源。' : '媒体库会从指定目录扫描视频文件。',
|
||
),
|
||
actions: _isBrowsing
|
||
? [
|
||
ShadButton.outline(
|
||
onPressed: () => setState(() => _isBrowsing = false),
|
||
child: const Text('返回设置'),
|
||
),
|
||
ShadButton(
|
||
onPressed: _useBrowserFolder,
|
||
leading: const Icon(Icons.check_rounded, size: 16),
|
||
child: const Text('使用此目录'),
|
||
),
|
||
]
|
||
: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton(
|
||
onPressed: _sources.isEmpty ? null : () => _save(context),
|
||
leading: const Icon(Icons.add_rounded, size: 16),
|
||
child: Text(_isEditing ? '保存并扫描' : '创建媒体库'),
|
||
),
|
||
],
|
||
child: _isBrowsing ? _folderBrowser(cs) : _form(cs),
|
||
);
|
||
}
|
||
|
||
Widget _form(ShadColorScheme cs) {
|
||
return SizedBox(
|
||
width: 520,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: cs.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.folder_rounded, color: cs.primary, size: 20),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'媒体来源 (${_sources.length})',
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
const Spacer(),
|
||
ShadButton.outline(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _startBrowsing,
|
||
leading: const Icon(Icons.add_rounded, size: 15),
|
||
child: const Text('添加目录'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (_sources.isEmpty)
|
||
Text(
|
||
'请至少添加一个云盘目录',
|
||
style: TextStyle(fontSize: 12, color: cs.destructive),
|
||
)
|
||
else
|
||
for (final source in _sources)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 5),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.folder_outlined,
|
||
size: 16,
|
||
color: cs.mutedForeground,
|
||
),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(
|
||
source.path,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
),
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: () => setState(
|
||
() => _sources.removeWhere(
|
||
(candidate) => candidate.id == source.id,
|
||
),
|
||
),
|
||
child: Icon(
|
||
Icons.remove_circle_outline_rounded,
|
||
size: 16,
|
||
color: cs.destructive,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
ShadInput(
|
||
controller: _nameController,
|
||
placeholder: const Text('媒体库名称'),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: ShadSelect<MediaLibraryKind>(
|
||
initialValue: _kind,
|
||
placeholder: const Text('媒体类型'),
|
||
selectedOptionBuilder: (context, value) => Text(value.title),
|
||
options: [
|
||
for (final value in MediaLibraryKind.values)
|
||
ShadOption(value: value, child: Text(value.title)),
|
||
],
|
||
onChanged: (value) {
|
||
if (value != null) setState(() => _kind = value);
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 132,
|
||
child: ShadInput(
|
||
controller: _minSizeController,
|
||
keyboardType: TextInputType.number,
|
||
placeholder: const Text('最小 MB'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: ShadCheckbox(
|
||
value: _recursive,
|
||
label: const Text('递归扫描所有子目录'),
|
||
sublabel: const Text('关闭后仅扫描当前目录中的视频文件'),
|
||
onChanged: (value) => setState(() => _recursive = value),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _folderBrowser(ShadColorScheme cs) {
|
||
return SizedBox(
|
||
width: 560,
|
||
height: 390,
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(7),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
ShadTooltip(
|
||
builder: (_) => const Text('返回上级目录'),
|
||
child: ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _browserPath.isEmpty
|
||
? null
|
||
: () {
|
||
setState(() => _browserPath.removeLast());
|
||
_loadFolders();
|
||
},
|
||
child: const Icon(Icons.arrow_back_rounded, size: 16),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Icon(Icons.folder_rounded, size: 16, color: cs.primary),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
_browserLocation,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 12, color: cs.foreground),
|
||
),
|
||
),
|
||
ShadTooltip(
|
||
builder: (_) => const Text('刷新目录'),
|
||
child: ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _isLoadingFolders ? null : _loadFolders,
|
||
child: const Icon(Icons.refresh_rounded, size: 16),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: _isLoadingFolders
|
||
? const Center(
|
||
child: SizedBox(width: 220, child: ShadProgress()),
|
||
)
|
||
: _folderError != null
|
||
? Center(
|
||
child: Text(
|
||
_folderError!,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: cs.destructive),
|
||
),
|
||
)
|
||
: _folders.isEmpty
|
||
? Center(
|
||
child: Text(
|
||
'此目录没有子文件夹',
|
||
style: TextStyle(color: cs.mutedForeground),
|
||
),
|
||
)
|
||
: ListView.separated(
|
||
itemCount: _folders.length,
|
||
separatorBuilder: (_, _) =>
|
||
Divider(height: 1, color: cs.border),
|
||
itemBuilder: (context, index) {
|
||
final folder = _folders[index];
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () {
|
||
setState(() => _browserPath.add(folder));
|
||
_loadFolders();
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 11,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.folder_rounded,
|
||
size: 19,
|
||
color: cs.primary,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
folder.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
Icon(
|
||
Icons.chevron_right_rounded,
|
||
size: 18,
|
||
color: cs.mutedForeground,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LibraryRow extends StatelessWidget {
|
||
final MediaLibraryDefinition library;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onEdit;
|
||
final VoidCallback onDelete;
|
||
|
||
const _LibraryRow({
|
||
required this.library,
|
||
required this.selected,
|
||
required this.onTap,
|
||
required this.onEdit,
|
||
required this.onDelete,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
decoration: BoxDecoration(
|
||
color: selected
|
||
? cs.primary.withValues(alpha: 0.08)
|
||
: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: selected ? cs.primary : cs.border),
|
||
),
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 8, 6, 8),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.video_library_rounded,
|
||
size: 20,
|
||
color: selected ? cs.primary : cs.mutedForeground,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
library.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: selected
|
||
? FontWeight.w700
|
||
: FontWeight.w500,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'${library.kind.title} · ${library.rootPath}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
ShadTooltip(
|
||
builder: (_) => const Text('管理媒体库'),
|
||
child: ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: onEdit,
|
||
child: const Icon(Icons.edit_outlined, size: 15),
|
||
),
|
||
),
|
||
ShadTooltip(
|
||
builder: (_) => const Text('删除媒体库'),
|
||
child: ShadButton.destructive(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: onDelete,
|
||
child: const Icon(Icons.delete_outline_rounded, size: 15),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MediaWork {
|
||
final String key;
|
||
final MediaLibraryItem primary;
|
||
final List<MediaLibraryItem> resources;
|
||
|
||
const _MediaWork({
|
||
required this.key,
|
||
required this.primary,
|
||
required this.resources,
|
||
});
|
||
|
||
static List<_MediaWork> fromItems(Iterable<MediaLibraryItem> items) {
|
||
final grouped = <String, List<MediaLibraryItem>>{};
|
||
for (final item in items) {
|
||
final title = item.title.toLowerCase().replaceAll(
|
||
RegExp(r'[^a-z0-9\u4e00-\u9fff]'),
|
||
'',
|
||
);
|
||
final kind = item.mediaKind?.name ?? 'unknown';
|
||
final key = item.tmdbID == null
|
||
? '$kind:$title:${item.year}'
|
||
: '$kind:tmdb:${item.tmdbID}';
|
||
grouped.putIfAbsent(key, () => []).add(item);
|
||
}
|
||
return grouped.entries.map((entry) {
|
||
final resources = entry.value
|
||
..sort(
|
||
(a, b) =>
|
||
a.file.name.toLowerCase().compareTo(b.file.name.toLowerCase()),
|
||
);
|
||
final primary = resources.reduce((best, candidate) {
|
||
final bestScore =
|
||
(best.hasChineseAudio ? 2 : 0) +
|
||
(best.hasChineseSubtitle ? 1 : 0) +
|
||
(best.posterPath?.isNotEmpty == true ? 1 : 0);
|
||
final candidateScore =
|
||
(candidate.hasChineseAudio ? 2 : 0) +
|
||
(candidate.hasChineseSubtitle ? 1 : 0) +
|
||
(candidate.posterPath?.isNotEmpty == true ? 1 : 0);
|
||
return candidateScore > bestScore ? candidate : best;
|
||
});
|
||
return _MediaWork(key: entry.key, primary: primary, resources: resources);
|
||
}).toList()..sort(
|
||
(a, b) => a.primary.title.toLowerCase().compareTo(
|
||
b.primary.title.toLowerCase(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MediaCollection {
|
||
final String key;
|
||
final String name;
|
||
final MediaLibraryItem primary;
|
||
final List<MediaLibraryItem> resources;
|
||
final int workCount;
|
||
|
||
const _MediaCollection({
|
||
required this.key,
|
||
required this.name,
|
||
required this.primary,
|
||
required this.resources,
|
||
required this.workCount,
|
||
});
|
||
|
||
static List<_MediaCollection> fromItems(Iterable<MediaLibraryItem> items) {
|
||
final grouped = <String, List<MediaLibraryItem>>{};
|
||
final names = <String, String>{};
|
||
for (final item in items) {
|
||
final name = item.collectionName?.trim();
|
||
if (name == null || name.isEmpty) continue;
|
||
final key = item.collectionID == null
|
||
? 'name:${name.toLowerCase()}'
|
||
: 'tmdb:${item.collectionID}';
|
||
grouped.putIfAbsent(key, () => []).add(item);
|
||
names[key] = name;
|
||
}
|
||
return grouped.entries
|
||
.map((entry) {
|
||
final resources = entry.value;
|
||
final works = _MediaWork.fromItems(resources);
|
||
final primary = works.map((work) => work.primary).reduce((
|
||
best,
|
||
candidate,
|
||
) {
|
||
final bestScore =
|
||
(best.posterPath?.isNotEmpty == true ? 1 : 0) +
|
||
(best.hasChineseAudio ? 1 : 0);
|
||
final candidateScore =
|
||
(candidate.posterPath?.isNotEmpty == true ? 1 : 0) +
|
||
(candidate.hasChineseAudio ? 1 : 0);
|
||
return candidateScore > bestScore ? candidate : best;
|
||
});
|
||
return _MediaCollection(
|
||
key: entry.key,
|
||
name: names[entry.key] ?? '未命名合集',
|
||
primary: primary,
|
||
resources: resources,
|
||
workCount: works.length,
|
||
);
|
||
})
|
||
.where((collection) => collection.workCount >= 2)
|
||
.toList()
|
||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||
}
|
||
}
|
||
|
||
class _MediaCollectionTile extends ConsumerWidget {
|
||
final _MediaCollection collection;
|
||
final VoidCallback onOpen;
|
||
|
||
const _MediaCollectionTile({required this.collection, required this.onOpen});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final item = collection.primary;
|
||
final posterURL = item.posterPath?.isNotEmpty == true
|
||
? _tmdbImageURL(item.posterPath!, size: 'w342')
|
||
: null;
|
||
return Semantics(
|
||
button: true,
|
||
label: '${collection.name},${collection.workCount} 部作品',
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onOpen,
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: cs.border),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: posterURL == null
|
||
? Center(
|
||
child: Icon(
|
||
Icons.collections_bookmark_rounded,
|
||
color: cs.mutedForeground,
|
||
size: 34,
|
||
),
|
||
)
|
||
: CachedNetworkImage(
|
||
imageUrl: posterURL,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: item.posterPath!,
|
||
size: 'w342',
|
||
fallback: Center(
|
||
child: Icon(
|
||
Icons.collections_bookmark_rounded,
|
||
color: cs.mutedForeground,
|
||
size: 34,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: 7,
|
||
bottom: 7,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 7,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.72),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
'${collection.workCount} 部',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
collection.name,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'${collection.workCount} 部作品 · 自动合集',
|
||
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MediaPosterTile extends ConsumerWidget {
|
||
final _MediaWork work;
|
||
final VoidCallback onOpen;
|
||
final VoidCallback onDownload;
|
||
final VoidCallback? onRescan;
|
||
|
||
const _MediaPosterTile({
|
||
required this.work,
|
||
required this.onOpen,
|
||
required this.onDownload,
|
||
this.onRescan,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final item = work.primary;
|
||
final isSeries = item.mediaKind == TMDBMediaKind.tv;
|
||
final posterURL = item.posterPath?.isNotEmpty == true
|
||
? _tmdbImageURL(item.posterPath!, size: 'w342')
|
||
: null;
|
||
return ShadContextMenuRegion(
|
||
items: [
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.info, size: 16),
|
||
onPressed: onOpen,
|
||
child: const Text('查看详情'),
|
||
),
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.download, size: 16),
|
||
onPressed: onDownload,
|
||
child: const Text('打开资源'),
|
||
),
|
||
const Divider(height: 8),
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.refreshCw, size: 16),
|
||
onPressed: onRescan,
|
||
child: const Text('重新扫描媒体库'),
|
||
),
|
||
],
|
||
child: Semantics(
|
||
button: true,
|
||
label: '${item.title},${work.resources.length} 个资源版本',
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onOpen,
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: cs.border),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: posterURL == null
|
||
? _posterFallback(cs, isSeries)
|
||
: CachedNetworkImage(
|
||
imageUrl: posterURL,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: item.posterPath!,
|
||
size: 'w342',
|
||
fallback: _posterFallback(cs, isSeries),
|
||
),
|
||
),
|
||
),
|
||
if (work.resources.length > 1)
|
||
Positioned(
|
||
right: 7,
|
||
bottom: 7,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 7,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.72),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
isSeries
|
||
? '${work.resources.length} 集'
|
||
: '${work.resources.length} 版本',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
item.title,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'${item.year.isEmpty ? '未知年份' : item.year} · ${isSeries ? '剧集' : '电影'}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _posterFallback(ShadColorScheme cs, bool isSeries) => Center(
|
||
child: Icon(
|
||
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
|
||
color: cs.mutedForeground,
|
||
size: 34,
|
||
),
|
||
);
|
||
}
|
||
|
||
class _MediaDetailPanel extends ConsumerStatefulWidget {
|
||
final _MediaWork work;
|
||
final VoidCallback onBack;
|
||
final ValueChanged<MediaLibraryItem> onDownload;
|
||
final ValueChanged<MediaLibraryItem> onPlay;
|
||
final ValueChanged<MediaLibraryItem> onExternalPlay;
|
||
final VoidCallback onManualMatch;
|
||
final VoidCallback onCancelRefresh;
|
||
final Future<void> Function() onRefreshAndRecognize;
|
||
|
||
const _MediaDetailPanel({
|
||
required this.work,
|
||
required this.onBack,
|
||
required this.onDownload,
|
||
required this.onPlay,
|
||
required this.onExternalPlay,
|
||
required this.onManualMatch,
|
||
required this.onCancelRefresh,
|
||
required this.onRefreshAndRecognize,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_MediaDetailPanel> createState() => _MediaDetailPanelState();
|
||
}
|
||
|
||
class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
|
||
late MediaLibraryItem _resource = widget.work.primary;
|
||
Map<String, dynamic>? _tmdbDetails;
|
||
int? _loadedTMDBID;
|
||
bool _loadingTMDBDetails = false;
|
||
bool _refreshingMedia = false;
|
||
final _backdropController = PageController();
|
||
Timer? _backdropTimer;
|
||
var _backdropIndex = 0;
|
||
final _updatePopover = ShadPopoverController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Future.microtask(_loadTMDBDetails);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _MediaDetailPanel oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
final updatedResource = widget.work.resources
|
||
.where((item) => item.id == _resource.id)
|
||
.firstOrNull;
|
||
if (updatedResource != null) {
|
||
_resource = updatedResource;
|
||
} else {
|
||
_resource = widget.work.primary;
|
||
}
|
||
if (widget.work.primary.tmdbID != _loadedTMDBID) {
|
||
Future.microtask(_loadTMDBDetails);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_backdropTimer?.cancel();
|
||
_backdropController.dispose();
|
||
_updatePopover.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Widget _updateMediaMenu() {
|
||
return ShadPopover(
|
||
controller: _updatePopover,
|
||
padding: const EdgeInsets.all(8),
|
||
popover: (_) => SizedBox(
|
||
width: 230,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
ShadButton.outline(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _refreshingMedia ? null : _refreshAndRecognize,
|
||
leading: const Icon(Icons.sync_rounded, size: 16),
|
||
child: const Text('同步云盘命名并识别'),
|
||
),
|
||
const SizedBox(height: 6),
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: () {
|
||
_updatePopover.hide();
|
||
widget.onManualMatch();
|
||
},
|
||
leading: const Icon(Icons.edit_note_rounded, size: 16),
|
||
child: const Text('手动选择匹配'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
child: ShadButton.outline(
|
||
onPressed: _refreshingMedia ? null : () => _updatePopover.toggle(),
|
||
leading: const Icon(Icons.expand_more_rounded, size: 16),
|
||
child: const Text('更新媒体'),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _refreshAndRecognize() async {
|
||
if (_refreshingMedia) return;
|
||
setState(() => _refreshingMedia = true);
|
||
try {
|
||
await widget.onRefreshAndRecognize();
|
||
await _loadTMDBDetails(force: true);
|
||
} finally {
|
||
if (mounted) setState(() => _refreshingMedia = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadTMDBDetails({bool force = false}) async {
|
||
final item = widget.work.primary;
|
||
final tmdbID = item.tmdbID;
|
||
final kind = item.mediaKind;
|
||
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
|
||
if (tmdbID == null ||
|
||
kind == null ||
|
||
apiKey.isEmpty ||
|
||
(!force && _loadedTMDBID == tmdbID) ||
|
||
_loadingTMDBDetails) {
|
||
return;
|
||
}
|
||
setState(() => _loadingTMDBDetails = true);
|
||
try {
|
||
final details = await ref
|
||
.read(authProvider.notifier)
|
||
.api
|
||
.tmdbDetails(
|
||
tmdbID,
|
||
mediaKind: kind == TMDBMediaKind.tv ? 'tv' : 'movie',
|
||
apiKey: apiKey,
|
||
proxyHost:
|
||
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
|
||
proxyPort:
|
||
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
|
||
);
|
||
if (mounted) {
|
||
setState(() {
|
||
_tmdbDetails = details;
|
||
_loadedTMDBID = tmdbID;
|
||
});
|
||
_restartBackdropCarousel();
|
||
}
|
||
} catch (_) {
|
||
// Artwork enrichments are optional and should not interrupt playback.
|
||
} finally {
|
||
if (mounted) setState(() => _loadingTMDBDetails = false);
|
||
}
|
||
}
|
||
|
||
List<String> _heroBackdropPaths(MediaLibraryItem item) {
|
||
final images = _tmdbDetails?['images'];
|
||
final paths = <String>[
|
||
if (item.backdropPath?.isNotEmpty == true) item.backdropPath!,
|
||
if (_tmdbDetails?['backdrop_path']?.toString().isNotEmpty == true)
|
||
_tmdbDetails!['backdrop_path'].toString(),
|
||
if (images is Map) ..._imagePaths(images['backdrops']),
|
||
];
|
||
return paths.toSet().take(10).toList();
|
||
}
|
||
|
||
void _restartBackdropCarousel() {
|
||
_backdropTimer?.cancel();
|
||
final count = _heroBackdropPaths(widget.work.primary).length;
|
||
if (count < 2) return;
|
||
_backdropIndex = 0;
|
||
if (_backdropController.hasClients) _backdropController.jumpToPage(0);
|
||
_backdropTimer = Timer.periodic(const Duration(seconds: 8), (_) {
|
||
if (!mounted || !_backdropController.hasClients) return;
|
||
_backdropIndex = (_backdropIndex + 1) % count;
|
||
_backdropController.animateToPage(
|
||
_backdropIndex,
|
||
duration: const Duration(milliseconds: 450),
|
||
curve: Curves.easeOut,
|
||
);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final item = widget.work.primary;
|
||
final isSeries = item.mediaKind == TMDBMediaKind.tv;
|
||
return Stack(
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: widget.onBack,
|
||
leading: const Icon(Icons.arrow_back_rounded, size: 16),
|
||
child: const Text('返回海报墙'),
|
||
),
|
||
ShadButton(
|
||
onPressed: () => widget.onPlay(_resource),
|
||
leading: const Icon(Icons.play_arrow_rounded, size: 16),
|
||
child: const Text('播放'),
|
||
),
|
||
ShadButton.outline(
|
||
onPressed: () => widget.onExternalPlay(_resource),
|
||
leading: const Icon(Icons.launch_rounded, size: 16),
|
||
child: const Text('外部播放'),
|
||
),
|
||
ShadButton.outline(
|
||
onPressed: () => widget.onDownload(_resource),
|
||
leading: const Icon(Icons.download_rounded, size: 16),
|
||
child: const Text('下载'),
|
||
),
|
||
_updateMediaMenu(),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_detailInformation(context, item, isSeries),
|
||
if (item.isMatched) ...[
|
||
const SizedBox(height: 22),
|
||
_tmdbEnrichment(context),
|
||
],
|
||
const SizedBox(height: 22),
|
||
_fileInformation(context, item),
|
||
const SizedBox(height: 26),
|
||
_resourceList(context, cs),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_refreshingMedia)
|
||
Positioned.fill(
|
||
child: ColoredBox(
|
||
color: cs.background.withValues(alpha: 0.86),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(
|
||
width: 38,
|
||
height: 38,
|
||
child: CircularProgressIndicator(strokeWidth: 3),
|
||
),
|
||
const SizedBox(height: 14),
|
||
const Text('正在同步、识别并匹配媒体信息'),
|
||
const SizedBox(height: 14),
|
||
ShadButton.destructive(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: widget.onCancelRefresh,
|
||
leading: const Icon(Icons.stop_rounded, size: 16),
|
||
child: const Text('取消'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _detailInformation(
|
||
BuildContext context,
|
||
MediaLibraryItem item,
|
||
bool isSeries,
|
||
) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final posterURL = item.posterPath?.isNotEmpty == true
|
||
? _tmdbImageURL(item.posterPath!, size: 'w342')
|
||
: null;
|
||
final backdrops = _heroBackdropPaths(item);
|
||
return SizedBox(
|
||
height: 340,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (backdrops.isNotEmpty)
|
||
PageView.builder(
|
||
controller: _backdropController,
|
||
itemCount: backdrops.length,
|
||
onPageChanged: (index) => _backdropIndex = index,
|
||
itemBuilder: (_, index) => CachedNetworkImage(
|
||
imageUrl: _tmdbImageURL(backdrops[index], size: 'w1280'),
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: backdrops[index],
|
||
size: 'w1280',
|
||
fallback: ColoredBox(color: cs.muted),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
ColoredBox(color: cs.muted),
|
||
ColoredBox(color: Colors.black.withValues(alpha: 0.58)),
|
||
Padding(
|
||
padding: const EdgeInsets.all(22),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
SizedBox(
|
||
width: 150,
|
||
height: 225,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: posterURL == null
|
||
? _detailPosterFallback(cs, isSeries)
|
||
: CachedNetworkImage(
|
||
imageUrl: posterURL,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: item.posterPath!,
|
||
size: 'w342',
|
||
fallback: _detailPosterFallback(cs, isSeries),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SelectableText(
|
||
item.title,
|
||
style: const TextStyle(
|
||
fontSize: 28,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
if (item.originalTitle.isNotEmpty &&
|
||
item.originalTitle != item.title) ...[
|
||
const SizedBox(height: 3),
|
||
SelectableText(
|
||
item.originalTitle,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: Colors.white.withValues(alpha: 0.72),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 10),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 6,
|
||
children: [
|
||
ShadBadge(child: Text(isSeries ? '剧集' : '电影')),
|
||
if (item.year.isNotEmpty)
|
||
ShadBadge.outline(child: Text(item.year)),
|
||
if (item.hasChineseAudio)
|
||
const ShadBadge.outline(child: Text('中文音轨')),
|
||
if (item.hasChineseSubtitle)
|
||
const ShadBadge.outline(child: Text('中文字幕')),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
SelectableText(
|
||
item.overview.isEmpty ? '暂无影视简介。' : item.overview,
|
||
maxLines: 4,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
height: 1.55,
|
||
color: Colors.white.withValues(alpha: 0.82),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _fileInformation(BuildContext context, MediaLibraryItem item) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final parsed = ParsedMediaName.parse(
|
||
_resource.file.name,
|
||
directoryName: _parentDirectoryName(_resource.file.cloudPath),
|
||
);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'文件与媒体信息',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Wrap(
|
||
spacing: 20,
|
||
runSpacing: 10,
|
||
children: [
|
||
_metadataPill(context, 'TMDB', item.tmdbID?.toString() ?? '未匹配'),
|
||
_metadataPill(context, '资源', '${widget.work.resources.length}'),
|
||
_metadataPill(context, '大小', _resource.file.formattedSize),
|
||
if (parsed.resolution != null)
|
||
_metadataPill(context, '分辨率', parsed.resolution!),
|
||
if (parsed.videoCodec != null)
|
||
_metadataPill(context, '编码', parsed.videoCodec!),
|
||
if (parsed.audio != null)
|
||
_metadataPill(context, '音频', parsed.audio!),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_metadataRow(context, '当前文件', _resource.file.name),
|
||
_metadataRow(context, '文件 ID', _resource.file.id),
|
||
_metadataRow(
|
||
context,
|
||
'GCID',
|
||
_resource.file.gcid?.isNotEmpty == true
|
||
? _resource.file.gcid!
|
||
: '未获取',
|
||
),
|
||
_metadataRow(context, '云盘位置', _resource.file.cloudPath),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _metadataPill(BuildContext context, String label, String value) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'$label ',
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
SelectableText(
|
||
value,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _tmdbEnrichment(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
if (_loadingTMDBDetails && _tmdbDetails == null) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14),
|
||
child: Row(children: [SizedBox(width: 100, child: ShadProgress())]),
|
||
);
|
||
}
|
||
final details = _tmdbDetails;
|
||
if (details == null) return const SizedBox.shrink();
|
||
final images = details['images'] is Map
|
||
? Map<String, dynamic>.from(details['images'] as Map)
|
||
: const <String, dynamic>{};
|
||
final posters = _imagePaths(images['posters']);
|
||
final credits = details['credits'] is Map
|
||
? Map<String, dynamic>.from(details['credits'] as Map)
|
||
: const <String, dynamic>{};
|
||
final cast =
|
||
(credits['cast'] as List?)
|
||
?.whereType<Map>()
|
||
.map((value) => Map<String, dynamic>.from(value))
|
||
.take(12)
|
||
.toList() ??
|
||
const <Map<String, dynamic>>[];
|
||
if (posters.isEmpty && cast.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (posters.isNotEmpty) ...[
|
||
Text(
|
||
'更多海报',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 160,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: posters.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||
itemBuilder: (context, index) => ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: CachedNetworkImage(
|
||
imageUrl: _tmdbImageURL(posters[index], size: 'w342'),
|
||
width: 106,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: posters[index],
|
||
size: 'w342',
|
||
width: 106,
|
||
fallback: const SizedBox(width: 106),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 18),
|
||
],
|
||
if (cast.isNotEmpty) ...[
|
||
Text(
|
||
'演职员',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 180,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: cast.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||
itemBuilder: (context, index) {
|
||
final person = cast[index];
|
||
final profile = person['profile_path']?.toString();
|
||
return SizedBox(
|
||
width: 96,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: profile == null || profile.isEmpty
|
||
? Container(
|
||
width: 96,
|
||
height: 122,
|
||
color: cs.muted,
|
||
child: Icon(
|
||
Icons.person_rounded,
|
||
color: cs.mutedForeground,
|
||
),
|
||
)
|
||
: CachedNetworkImage(
|
||
imageUrl: _tmdbImageURL(profile, size: 'w185'),
|
||
width: 96,
|
||
height: 122,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: profile,
|
||
size: 'w185',
|
||
width: 96,
|
||
height: 122,
|
||
fallback: Container(
|
||
width: 96,
|
||
height: 122,
|
||
color: cs.muted,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 5),
|
||
Text(
|
||
person['name']?.toString() ?? '',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
Text(
|
||
person['character']?.toString() ?? '',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
List<String> _imagePaths(dynamic value) {
|
||
if (value is! List) return const [];
|
||
return value
|
||
.whereType<Map>()
|
||
.map((image) => image['file_path']?.toString())
|
||
.whereType<String>()
|
||
.where((path) => path.isNotEmpty)
|
||
.take(12)
|
||
.toList();
|
||
}
|
||
|
||
Widget _detailPosterFallback(ShadColorScheme cs, bool isSeries) => Container(
|
||
color: cs.muted,
|
||
child: Center(
|
||
child: Icon(
|
||
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
|
||
color: cs.mutedForeground,
|
||
size: 42,
|
||
),
|
||
),
|
||
);
|
||
|
||
Widget _metadataRow(BuildContext context, String label, String value) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 7),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 64,
|
||
child: SelectableText(
|
||
label,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SelectableText(
|
||
value,
|
||
style: TextStyle(fontSize: 12, color: cs.foreground),
|
||
maxLines: 2,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _resourceList(BuildContext context, ShadColorScheme cs) {
|
||
final isSeries = widget.work.primary.mediaKind == TMDBMediaKind.tv;
|
||
final episodesBySeason = <int, List<MediaLibraryItem>>{};
|
||
if (isSeries) {
|
||
for (final resource in widget.work.resources) {
|
||
final parsed = ParsedMediaName.parse(resource.file.name);
|
||
(episodesBySeason[parsed.season ?? 1] ??= []).add(resource);
|
||
}
|
||
for (final values in episodesBySeason.values) {
|
||
values.sort((a, b) {
|
||
final aEpisode = ParsedMediaName.parse(a.file.name).episode ?? 9999;
|
||
final bEpisode = ParsedMediaName.parse(b.file.name).episode ?? 9999;
|
||
return aEpisode == bEpisode
|
||
? a.file.name.compareTo(b.file.name)
|
||
: aEpisode.compareTo(bEpisode);
|
||
});
|
||
}
|
||
}
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
isSeries
|
||
? '剧集 (${widget.work.resources.length} 集)'
|
||
: widget.work.resources.length > 1
|
||
? '资源版本 (${widget.work.resources.length})'
|
||
: '媒体资源',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (isSeries && episodesBySeason.isNotEmpty)
|
||
for (final entry in episodesBySeason.entries) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8, bottom: 6),
|
||
child: Text(
|
||
'第 ${entry.key} 季',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
),
|
||
...entry.value.map(
|
||
(resource) => _resourceTile(
|
||
resource,
|
||
cs,
|
||
episode: ParsedMediaName.parse(resource.file.name).episode,
|
||
),
|
||
),
|
||
]
|
||
else
|
||
...widget.work.resources.map(
|
||
(resource) => _resourceTile(resource, cs),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _resourceTile(
|
||
MediaLibraryItem resource,
|
||
ShadColorScheme cs, {
|
||
int? episode,
|
||
}) {
|
||
final selected = resource.id == _resource.id;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: ShadContextMenuRegion(
|
||
items: [
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.play, size: 16),
|
||
onPressed: () => widget.onPlay(resource),
|
||
child: const Text('播放'),
|
||
),
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.monitorPlay, size: 16),
|
||
onPressed: () => widget.onExternalPlay(resource),
|
||
child: const Text('外部播放器'),
|
||
),
|
||
ShadContextMenuItem.inset(
|
||
leading: const Icon(LucideIcons.download, size: 16),
|
||
onPressed: () => widget.onDownload(resource),
|
||
child: const Text('下载'),
|
||
),
|
||
],
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: () => setState(() => _resource = resource),
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: selected ? cs.primary.withValues(alpha: 0.10) : cs.card,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: selected ? cs.primary : cs.border),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
if (episode != null)
|
||
SizedBox(
|
||
width: 38,
|
||
child: Text(
|
||
'E${episode.toString().padLeft(2, '0')}',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.primary,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SelectableText(
|
||
resource.file.name,
|
||
maxLines: 2,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SelectableText(
|
||
'${resource.file.formattedSize} · ${resource.file.modifiedAt}',
|
||
maxLines: 1,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (selected)
|
||
Icon(
|
||
Icons.check_circle_rounded,
|
||
size: 16,
|
||
color: cs.primary,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ManualTMDBMatchDialog extends ConsumerStatefulWidget {
|
||
final String initialQuery;
|
||
final List<Map<String, dynamic>>? initialResults;
|
||
|
||
const _ManualTMDBMatchDialog({
|
||
required this.initialQuery,
|
||
this.initialResults,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_ManualTMDBMatchDialog> createState() =>
|
||
_ManualTMDBMatchDialogState();
|
||
}
|
||
|
||
class _ManualTMDBMatchDialogState
|
||
extends ConsumerState<_ManualTMDBMatchDialog> {
|
||
late final TextEditingController _queryController;
|
||
bool _searching = false;
|
||
String? _error;
|
||
List<Map<String, dynamic>> _results = const [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_queryController = TextEditingController(text: widget.initialQuery);
|
||
if (widget.initialResults != null) {
|
||
_results = widget.initialResults!;
|
||
} else {
|
||
Future.microtask(_search);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_queryController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _search() async {
|
||
final query = _queryController.text.trim();
|
||
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
|
||
if (query.isEmpty || apiKey.isEmpty) {
|
||
setState(() {
|
||
_results = const [];
|
||
_error = apiKey.isEmpty ? '请先在设置中配置 TMDB API Key。' : null;
|
||
});
|
||
return;
|
||
}
|
||
setState(() {
|
||
_searching = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final result = await ref
|
||
.read(authProvider.notifier)
|
||
.api
|
||
.tmdbSearch(
|
||
query,
|
||
apiKey: apiKey,
|
||
proxyHost:
|
||
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
|
||
proxyPort:
|
||
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
|
||
);
|
||
final values =
|
||
(result['results'] as List?)
|
||
?.whereType<Map>()
|
||
.map(Map<String, dynamic>.from)
|
||
.where(
|
||
(item) =>
|
||
item['id'] != null &&
|
||
(item['media_type'] == 'movie' ||
|
||
item['media_type'] == 'tv'),
|
||
)
|
||
.toList() ??
|
||
const <Map<String, dynamic>>[];
|
||
if (mounted) setState(() => _results = values);
|
||
} catch (error) {
|
||
if (mounted) setState(() => _error = error.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _searching = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ShadDialog(
|
||
title: const Text('手动匹配 TMDB'),
|
||
description: const Text('选择一个结果后,会应用到该作品的全部资源版本。'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton(
|
||
onPressed: _searching ? null : _search,
|
||
leading: const Icon(Icons.search_rounded, size: 16),
|
||
child: const Text('搜索'),
|
||
),
|
||
],
|
||
child: SizedBox(
|
||
width: 620,
|
||
height: 500,
|
||
child: Column(
|
||
children: [
|
||
ShadInput(
|
||
controller: _queryController,
|
||
placeholder: const Text('输入片名'),
|
||
onSubmitted: (_) => _search(),
|
||
leading: Icon(
|
||
Icons.search_rounded,
|
||
size: 16,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: _searching
|
||
? const Center(child: ShadProgress())
|
||
: _error != null
|
||
? Center(
|
||
child: Text(
|
||
_error!,
|
||
style: TextStyle(color: cs.destructive),
|
||
),
|
||
)
|
||
: _results.isEmpty
|
||
? Center(
|
||
child: Text(
|
||
'没有匹配结果',
|
||
style: TextStyle(color: cs.mutedForeground),
|
||
),
|
||
)
|
||
: ListView.separated(
|
||
itemCount: _results.length,
|
||
separatorBuilder: (_, _) =>
|
||
Divider(height: 1, color: cs.border),
|
||
itemBuilder: (context, index) =>
|
||
_candidateRow(context, cs, _results[index]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _candidateRow(
|
||
BuildContext context,
|
||
ShadColorScheme cs,
|
||
Map<String, dynamic> candidate,
|
||
) {
|
||
final title = (candidate['title'] ?? candidate['name'] ?? '未知标题')
|
||
.toString();
|
||
final release =
|
||
(candidate['release_date'] ?? candidate['first_air_date'] ?? '')
|
||
.toString();
|
||
final posterPath = candidate['poster_path']?.toString();
|
||
return Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: () => Navigator.of(context).pop(candidate),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 44,
|
||
height: 64,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: posterPath == null || posterPath.isEmpty
|
||
? Container(
|
||
color: cs.muted,
|
||
child: Icon(
|
||
Icons.movie_rounded,
|
||
size: 20,
|
||
color: cs.mutedForeground,
|
||
),
|
||
)
|
||
: CachedNetworkImage(
|
||
imageUrl: _tmdbImageURL(posterPath, size: 'w154'),
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||
path: posterPath,
|
||
size: 'w154',
|
||
fallback: Container(
|
||
color: cs.muted,
|
||
child: Icon(
|
||
Icons.movie_rounded,
|
||
size: 20,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'${release.length >= 4 ? release.substring(0, 4) : '未知年份'} · ${candidate['media_type'] == 'tv' ? '剧集' : '电影'}',
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
candidate['overview']?.toString() ?? '',
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right_rounded, color: cs.mutedForeground),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|