Files
guangya_flutter/lib/pages/media_library_page.dart
T
2026-07-18 12:42:51 +08:00

1198 lines
39 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
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> {
String _tmdbApiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
bool _showApiKeyInput = false;
bool _tmdbSearching = false;
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
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)
.api;
ref.read(mediaLibraryProvider.notifier).load();
});
}
@override
void dispose() {
_apiKeyController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(mediaLibraryProvider);
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),
if (state.errorMessage != null || state.statusMessage != null)
_buildMessageBar(context, state),
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()),
_statPill(context, '电影', stats.movies.toString()),
_statPill(context, '剧集', stats.series.toString()),
_statPill(context, '待匹配', stats.unmatched.toString()),
],
);
}
Widget _statPill(BuildContext context, String label, String value) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Text(
'$label $value',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
);
}
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: state.selectedLibrary == null || state.isScanning
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.scanSelectedLibrary(),
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.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
: () => _searchTMDB(_searchController.text),
leading: const Icon(Icons.travel_explore_rounded, size: 16),
child: const Text('匹配'),
),
],
);
}
Widget _buildMessageBar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final isError = state.errorMessage != null;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isError ? cs.destructive.withValues(alpha: 0.08) : cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isError ? cs.destructive : cs.border),
),
child: Text(
state.errorMessage ?? state.statusMessage ?? '',
style: TextStyle(
fontSize: 12,
color: isError ? cs.destructive : cs.mutedForeground,
),
),
);
}
Widget _buildLibraryList(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_showApiKeyInput) _buildTMDBConfig(context),
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),
onDelete: () => ref
.read(mediaLibraryProvider.notifier)
.deleteLibrary(library.id),
);
},
),
),
],
),
);
}
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(
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 (state.isScanning) {
return _scanProgress(context, state);
}
if (_tmdbSearching || _tmdbResults.isNotEmpty || _tmdbError != null) {
return _tmdbResultPanel(context);
}
final items = state.visibleItems;
if (state.selectedLibrary == null) {
return _mainEmpty(context, '还没有媒体库', '从云盘根目录或当前目录创建一个媒体库');
}
if (items.isEmpty) {
return _mainEmpty(context, '没有扫描结果', '点击扫描读取该媒体库下的视频文件');
}
return LayoutBuilder(
builder: (context, constraints) {
final columns = (constraints.maxWidth / 220).floor().clamp(2, 6);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.8,
),
itemCount: items.length,
itemBuilder: (context, index) => _MediaItemTile(item: items[index]),
);
},
);
}
Widget _scanProgress(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ShadProgress(),
const SizedBox(height: 16),
Text(
state.progress.phase,
textAlign: TextAlign.center,
style: TextStyle(color: cs.foreground),
),
const SizedBox(height: 6),
Text(
'已发现 ${state.progress.completed} 个视频文件',
style: TextStyle(fontSize: 12, color: 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)
: Image.network(
'https://image.tmdb.org/t/p/w200$posterPath',
width: 74,
height: 110,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_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,
),
);
}
void _saveApiKey() {
final key = _apiKeyController.text.trim();
StorageManager.set(StorageKeys.tmdbApiKey, key);
setState(() {
_tmdbApiKey = key;
_showApiKeyInput = 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;
});
}
}
}
class _CreateMediaLibraryDialog extends ConsumerStatefulWidget {
final String? initialRootID;
final String initialPath;
final String initialName;
const _CreateMediaLibraryDialog({
required this.initialRootID,
required this.initialPath,
required this.initialName,
});
@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>[];
@override
void initState() {
super.initState();
_sources = [
MediaLibrarySource(
id: 'initial-source',
rootID: widget.initialRootID,
path: widget.initialPath,
),
];
_nameController = TextEditingController(text: widget.initialName);
}
@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;
});
}
void _create(BuildContext context) {
ref
.read(mediaLibraryProvider.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,
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadDialog(
title: Text(_isBrowsing ? '选择云盘文件夹' : '创建媒体库'),
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 : () => _create(context),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('创建媒体库'),
),
],
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 onDelete;
const _LibraryRow({
required this.library,
required this.selected,
required this.onTap,
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.destructive(
size: ShadButtonSize.sm,
onPressed: onDelete,
child: const Icon(Icons.delete_outline_rounded, size: 15),
),
),
],
),
),
),
),
);
}
}
class _MediaItemTile extends StatelessWidget {
final MediaLibraryItem item;
const _MediaItemTile({required this.item});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final posterURL = item.posterPath == null || item.posterPath!.isEmpty
? null
: 'https://image.tmdb.org/t/p/w342${item.posterPath}';
final isSeries = item.mediaKind == TMDBMediaKind.tv;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Row(
children: [
Container(
width: 54,
height: 76,
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(6),
),
clipBehavior: Clip.antiAlias,
child: posterURL == null
? Icon(
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
size: 24,
color: cs.mutedForeground,
)
: Image.network(
posterURL,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
size: 24,
color: cs.mutedForeground,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
item.year.isEmpty
? item.title
: '${item.title} (${item.year})',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
),
if (item.isMatched)
ShadBadge(child: Text(isSeries ? '剧集' : '电影')),
],
),
const SizedBox(height: 4),
Text(
item.isMatched
? (item.overview.isEmpty ? 'TMDB 已识别' : item.overview)
: '未识别,可在 TMDB 工具中重新匹配',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
const SizedBox(height: 4),
Text(
item.file.cloudPath,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10, color: cs.mutedForeground),
),
],
),
),
],
),
);
}
}