feat: add media collection wall

This commit is contained in:
ngfchl
2026-07-18 14:04:48 +08:00
parent 3d757ad77f
commit 9d2ed8f2d9
+252 -6
View File
@@ -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<MediaLibraryPage> {
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<MediaLibraryPage> {
'全部',
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<MediaLibraryPage> {
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<MediaLibraryPage> {
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<MediaLibraryPage> {
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<MediaLibraryPage> {
);
},
);
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<MediaLibraryPage> {
);
}
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<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
? '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<Uint8List?>(
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;