From 1b8911e4b6e1566e4fb60705f6c5950ac704a8f0 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 14:22:52 +0800 Subject: [PATCH] feat: show TMDB artwork and cast in media details --- lib/pages/media_library_page.dart | 241 +++++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 1 deletion(-) diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index af94af6..47cad80 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1809,6 +1809,15 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { late MediaLibraryItem _resource = widget.work.primary; + Map? _tmdbDetails; + int? _loadedTMDBID; + bool _loadingTMDBDetails = false; + + @override + void initState() { + super.initState(); + Future.microtask(_loadTMDBDetails); + } @override void didUpdateWidget(covariant _MediaDetailPanel oldWidget) { @@ -1816,6 +1825,48 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { if (!widget.work.resources.any((item) => item.id == _resource.id)) { _resource = widget.work.primary; } + if (widget.work.primary.tmdbID != _loadedTMDBID) { + Future.microtask(_loadTMDBDetails); + } + } + + Future _loadTMDBDetails() async { + final item = widget.work.primary; + final tmdbID = item.tmdbID; + final kind = item.mediaKind; + final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; + if (tmdbID == null || + kind == null || + apiKey.isEmpty || + _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(StorageKeys.tmdbProxyHost) ?? '', + proxyPort: + StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', + ); + if (mounted) { + setState(() { + _tmdbDetails = details; + _loadedTMDBID = tmdbID; + }); + } + } catch (_) { + // Artwork enrichments are optional and should not interrupt playback. + } finally { + if (mounted) setState(() => _loadingTMDBDetails = false); + } } @override @@ -1870,7 +1921,16 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 700; - final information = _detailInformation(context, item, isSeries); + final information = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailInformation(context, item, isSeries), + if (item.isMatched) ...[ + const SizedBox(height: 22), + _tmdbEnrichment(context), + ], + ], + ); final resources = _resourceList(context, cs); return SingleChildScrollView( child: compact @@ -1991,6 +2051,185 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { ); } + 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.from(details['images'] as Map) + : const {}; + final posters = _imagePaths(images['posters']); + final backdrops = _imagePaths(images['backdrops']); + final credits = details['credits'] is Map + ? Map.from(details['credits'] as Map) + : const {}; + final cast = + (credits['cast'] as List?) + ?.whereType() + .map((value) => Map.from(value)) + .take(12) + .toList() ?? + const >[]; + if (posters.isEmpty && backdrops.isEmpty && cast.isEmpty) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (backdrops.isNotEmpty) ...[ + Text( + '横幅与剧照', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: cs.foreground, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 155, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: backdrops.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) => ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + 'https://image.tmdb.org/t/p/w780${backdrops[index]}', + width: 260, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox(width: 260), + ), + ), + ), + ), + const SizedBox(height: 18), + ], + 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: Image.network( + 'https://image.tmdb.org/t/p/w342${posters[index]}', + width: 106, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => 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, + ), + ) + : Image.network( + 'https://image.tmdb.org/t/p/w185$profile', + width: 96, + height: 122, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => 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 _imagePaths(dynamic value) { + if (value is! List) return const []; + return value + .whereType() + .map((image) => image['file_path']?.toString()) + .whereType() + .where((path) => path.isNotEmpty) + .take(12) + .toList(); + } + Widget _detailPosterFallback(ShadColorScheme cs, bool isSeries) => Container( color: cs.muted, child: Center(