diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 73b385b..bee1548 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -12,7 +12,7 @@ import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; import '../providers/media_library_provider.dart'; -enum _MediaWallFilter { all, movies, series, unmatched } +enum _MediaWallFilter { all, movies, series, collections, unmatched } class MediaLibraryPage extends ConsumerStatefulWidget { final bool showLibrarySidebar; @@ -41,6 +41,7 @@ class _MediaLibraryPageState extends ConsumerState { bool _backupBusy = false; _MediaWork? _detailWork; _MediaWallFilter _wallFilter = _MediaWallFilter.all; + String? _activeCollectionKey; final _apiKeyController = TextEditingController(); final _searchController = TextEditingController(); @@ -128,33 +129,48 @@ class _MediaLibraryPageState extends ConsumerState { '全部', stats.total.toString(), selected: _wallFilter == _MediaWallFilter.all, - onTap: () => setState(() => _wallFilter = _MediaWallFilter.all), + onTap: () => _selectWallFilter(_MediaWallFilter.all), ), _statPill( context, '电影', stats.movies.toString(), selected: _wallFilter == _MediaWallFilter.movies, - onTap: () => setState(() => _wallFilter = _MediaWallFilter.movies), + onTap: () => _selectWallFilter(_MediaWallFilter.movies), ), _statPill( context, '剧集', stats.series.toString(), selected: _wallFilter == _MediaWallFilter.series, - onTap: () => setState(() => _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: () => setState(() => _wallFilter = _MediaWallFilter.unmatched), + onTap: () => _selectWallFilter(_MediaWallFilter.unmatched), ), ], ); } + void _selectWallFilter(_MediaWallFilter filter) { + setState(() { + _wallFilter = filter; + _activeCollectionKey = null; + _detailWork = null; + }); + } + Widget _statPill( BuildContext context, String label, @@ -398,6 +414,10 @@ class _MediaLibraryPageState extends ConsumerState { 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 => @@ -408,6 +428,7 @@ class _MediaLibraryPageState extends ConsumerState { visibleItems .where((item) => item.mediaKind == TMDBMediaKind.tv) .toList(), + _MediaWallFilter.collections => activeCollection?.resources ?? const [], _MediaWallFilter.unmatched => visibleItems.where((item) => !item.isMatched).toList(), }; @@ -434,7 +455,11 @@ class _MediaLibraryPageState extends ConsumerState { ref.read(mediaLibraryProvider.notifier).scanSelectedLibrary(), ); } - final content = works.isEmpty + final showingCollectionOverview = + _wallFilter == _MediaWallFilter.collections && activeCollection == null; + final wallContent = showingCollectionOverview + ? _collectionOverview(context, collections) + : works.isEmpty ? _mainEmpty( context, state.isScanning @@ -469,6 +494,21 @@ class _MediaLibraryPageState extends ConsumerState { ); }, ); + 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) return content; return Column( children: [ @@ -479,6 +519,37 @@ class _MediaLibraryPageState extends ConsumerState { ); } + 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( @@ -1384,6 +1455,181 @@ class _MediaWork { } } +class _MediaCollection { + final String key; + final String name; + final MediaLibraryItem primary; + final List 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 items) { + final grouped = >{}; + final names = {}; + 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 + ? 'https://image.tmdb.org/t/p/w342${item.posterPath}' + : 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: FutureBuilder( + future: ref + .read(mediaLibraryProvider.notifier) + .posterBytes(item), + builder: (context, snapshot) { + if (snapshot.data != null) { + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + ); + } + if (posterURL != null) { + return Image.network( + posterURL, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Center( + child: Icon( + Icons.collections_bookmark_rounded, + color: cs.mutedForeground, + size: 34, + ), + ), + ); + } + return 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;