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 createState() => _MediaLibraryPageState(); } class _MediaLibraryPageState extends ConsumerState { String _tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; bool _showApiKeyInput = false; bool _tmdbSearching = false; String? _tmdbError; List> _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 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 _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(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); final results = (result['results'] as List?) ?.whereType() .where( (item) => item['media_type'] == 'movie' || item['media_type'] == 'tv', ) .map((item) => Map.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 String? _rootID; late String _rootPath; final _browserPath = []; var _folders = []; @override void initState() { super.initState(); _rootID = widget.initialRootID; _rootPath = 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 _startBrowsing() async { setState(() { _isBrowsing = true; _browserPath.clear(); _folders = []; }); await _loadFolders(); } Future _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 _extractFiles(Map value) { final files = []; final ids = {}; void visit(dynamic node) { if (node is Map) { try { final file = CloudFile.fromJson(Map.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() { setState(() { _rootID = _browserFolderID; _rootPath = _browserLocation; 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: _rootID, rootPath: _rootPath, 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: () => _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: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: cs.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(7), ), child: Icon(Icons.folder_rounded, color: cs.primary), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '媒体来源', style: TextStyle( fontSize: 12, color: cs.mutedForeground, ), ), const SizedBox(height: 2), Text( _rootPath, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w600, color: cs.foreground, ), ), ], ), ), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _startBrowsing, leading: const Icon(Icons.folder_open_rounded, size: 15), child: const Text('浏览'), ), ], ), ), const SizedBox(height: 14), ShadInput( controller: _nameController, placeholder: const Text('媒体库名称'), ), const SizedBox(height: 10), Row( children: [ Expanded( child: ShadSelect( 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), ), ], ), ), ], ), ); } }