From c2ea95612b0bd449b88a390914f0dd73a02a3daf Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 21:27:49 +0800 Subject: [PATCH] recover renamed media and rotate backdrops --- lib/pages/media_library_page.dart | 96 ++++++++++++----------- lib/providers/media_library_provider.dart | 56 ++++++++++++- 2 files changed, 104 insertions(+), 48 deletions(-) diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 291dca0..2753f2b 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1954,6 +1954,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { int? _loadedTMDBID; bool _loadingTMDBDetails = false; bool _refreshingMedia = false; + final _backdropController = PageController(); + Timer? _backdropTimer; + var _backdropIndex = 0; final _updatePopover = ShadPopoverController(); @override @@ -1980,6 +1983,8 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { @override void dispose() { + _backdropTimer?.cancel(); + _backdropController.dispose(); _updatePopover.dispose(); super.dispose(); } @@ -2063,6 +2068,7 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { _tmdbDetails = details; _loadedTMDBID = tmdbID; }); + _restartBackdropCarousel(); } } catch (_) { // Artwork enrichments are optional and should not interrupt playback. @@ -2071,6 +2077,34 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { } } + List _heroBackdropPaths(MediaLibraryItem item) { + final images = _tmdbDetails?['images']; + final paths = [ + if (item.backdropPath?.isNotEmpty == true) item.backdropPath!, + if (_tmdbDetails?['backdrop_path']?.toString().isNotEmpty == true) + _tmdbDetails!['backdrop_path'].toString(), + if (images is Map) ..._imagePaths(images['backdrops']), + ]; + return paths.toSet().take(10).toList(); + } + + void _restartBackdropCarousel() { + _backdropTimer?.cancel(); + final count = _heroBackdropPaths(widget.work.primary).length; + if (count < 2) return; + _backdropIndex = 0; + if (_backdropController.hasClients) _backdropController.jumpToPage(0); + _backdropTimer = Timer.periodic(const Duration(seconds: 8), (_) { + if (!mounted || !_backdropController.hasClients) return; + _backdropIndex = (_backdropIndex + 1) % count; + _backdropController.animateToPage( + _backdropIndex, + duration: const Duration(milliseconds: 450), + curve: Curves.easeOut, + ); + }); + } + @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; @@ -2163,9 +2197,7 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { final posterURL = item.posterPath?.isNotEmpty == true ? _tmdbImageURL(item.posterPath!, size: 'w342') : null; - final backdropPath = item.backdropPath?.isNotEmpty == true - ? item.backdropPath - : _tmdbDetails?['backdrop_path']?.toString(); + final backdrops = _heroBackdropPaths(item); return SizedBox( height: 340, child: ClipRRect( @@ -2173,14 +2205,19 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { child: Stack( fit: StackFit.expand, children: [ - if (backdropPath != null && backdropPath.isNotEmpty) - CachedNetworkImage( - imageUrl: _tmdbImageURL(backdropPath, size: 'w1280'), - fit: BoxFit.cover, - errorWidget: (_, _, _) => _tmdbDirectFallback( - path: backdropPath, - size: 'w1280', - fallback: ColoredBox(color: cs.muted), + if (backdrops.isNotEmpty) + PageView.builder( + controller: _backdropController, + itemCount: backdrops.length, + onPageChanged: (index) => _backdropIndex = index, + itemBuilder: (_, index) => CachedNetworkImage( + imageUrl: _tmdbImageURL(backdrops[index], size: 'w1280'), + fit: BoxFit.cover, + errorWidget: (_, _, _) => _tmdbDirectFallback( + path: backdrops[index], + size: 'w1280', + fallback: ColoredBox(color: cs.muted), + ), ), ) else @@ -2353,7 +2390,6 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { ? 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 {}; @@ -2364,46 +2400,12 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { .take(12) .toList() ?? const >[]; - if (posters.isEmpty && backdrops.isEmpty && cast.isEmpty) { + if (posters.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: CachedNetworkImage( - imageUrl: _tmdbImageURL(backdrops[index], size: 'w780'), - width: 260, - fit: BoxFit.cover, - errorWidget: (_, _, _) => _tmdbDirectFallback( - path: backdrops[index], - size: 'w780', - width: 260, - fallback: const SizedBox(width: 260), - ), - ), - ), - ), - ), - const SizedBox(height: 18), - ], if (posters.isNotEmpty) ...[ Text( '更多海报', diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 3657edb..9f40825 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -1625,8 +1625,11 @@ class MediaLibraryNotifier extends StateNotifier { if (current != null) { return _cacheResolvedCloudFile(knownFile, current); } - } catch (_) { + } catch (error) { // A rename can replace the cloud record. Locate its new ID below. + _appendScanLog( + '[同步识别][DEBUG] 旧 File ID 查询失败:${knownFile.id},$error;开始回退定位', + ); } final cached = await FileMetadataCache.file(knownFile.id); @@ -1686,6 +1689,57 @@ class MediaLibraryNotifier extends StateNotifier { } return _cacheResolvedCloudFile(knownFile, resolved); } + + // The user may have renamed the file outside this app, so its old name is + // no longer searchable. Resolve the previous parent directory by name and + // compare every child by GCID, which remains stable across a rename. + final gcid = knownFile.gcid?.trim(); + final parentName = _parentDirectoryName(knownFile.cloudPath); + if (gcid != null && gcid.isNotEmpty && parentName != null) { + try { + _appendScanLog('[同步识别][DEBUG] 按父目录 GCID 定位:目录=$parentName,gcid=$gcid'); + final foldersResponse = await _api!.searchFiles( + parentName, + page: 0, + pageSize: 100, + ); + final folders = _extractFiles( + foldersResponse, + ).where((file) => file.isDirectory && file.name == parentName); + for (final folder in folders) { + final response = await _api!.fsFiles( + parentID: folder.id, + page: 0, + pageSize: 1000, + orderBy: 0, + sortType: 0, + ); + for (final candidate in _extractFiles(response)) { + if (candidate.isDirectory) continue; + var resolved = candidate; + if (resolved.gcid != gcid) { + try { + final detail = await _api!.fsDetail(candidate.id); + resolved = + _fileFromDetail(detail, candidate.id, candidate) ?? + candidate; + } catch (_) { + continue; + } + } + if (resolved.gcid == gcid) { + _appendScanLog( + '[同步识别][DEBUG] GCID 定位成功:${knownFile.id} -> ' + '${resolved.id},名称=${resolved.name}', + ); + return _cacheResolvedCloudFile(knownFile, resolved); + } + } + } + } catch (_) { + // The caller emits the final diagnostic after all resolution paths fail. + } + } return null; }