diff --git a/docs/10-project/bilibili-integration.md b/docs/10-project/bilibili-integration.md index e2c5ebc..719943f 100644 --- a/docs/10-project/bilibili-integration.md +++ b/docs/10-project/bilibili-integration.md @@ -88,6 +88,8 @@ WBI 相关真源: - 需要登录 - 收藏夹 API 返回分页数据 +- 导入对话框支持标签切换:"我的收藏夹"(调用 `/x/v3/fav/folder/created/list-all`)和"收藏的收藏夹"(调用 `/x/v3/fav/folder/collected/list`) +- 收藏的他人收藏夹会展示 UP 主名称 - 实际导入按 `bvid + cid` 去重 - 多 P 视频并不是天然完整导入,必须结合具体 `cid` - 导入时会复用本地已有歌曲,不会盲目重复建 Song diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 93aa27e..c449b03 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -622,6 +622,7 @@ class AppTheme { AppThemePalette palette, ) { final isWindows = defaultTargetPlatform == TargetPlatform.windows; + final isLinux = defaultTargetPlatform == TargetPlatform.linux; final bodyWeight = isWindows ? FontWeight.w400 : FontWeight.w500; TextStyle tune( @@ -640,18 +641,31 @@ class AppTheme { color: color ?? palette.textPrimary, ); - if (!isWindows) { - return tuned; + if (isWindows) { + return tuned.copyWith( + fontFamily: 'Microsoft YaHei UI', + fontFamilyFallback: const [ + 'Microsoft YaHei', + 'Segoe UI', + 'Arial', + ], + ); } - return tuned.copyWith( - fontFamily: 'Microsoft YaHei UI', - fontFamilyFallback: const [ - 'Microsoft YaHei', - 'Segoe UI', - 'Arial', - ], - ); + if (isLinux) { + return tuned.copyWith( + fontFamilyFallback: const [ + 'Noto Sans CJK SC', + 'Noto Sans SC', + 'WenQuanYi Micro Hei', + 'Source Han Sans SC', + 'Noto Sans', + 'sans-serif', + ], + ); + } + + return tuned; } return base.copyWith( diff --git a/lib/features/playlist/application/bili_fav_import_notifier.dart b/lib/features/playlist/application/bili_fav_import_notifier.dart index d1e3e35..19ede5e 100644 --- a/lib/features/playlist/application/bili_fav_import_notifier.dart +++ b/lib/features/playlist/application/bili_fav_import_notifier.dart @@ -29,7 +29,8 @@ class BiliFavImportState with _$BiliFavImportState { /// 已加载收藏夹列表,等待用户选择 const factory BiliFavImportState.foldersLoaded( - List folders, + List createdFolders, + List collectedFolders, ) = _FoldersLoaded; /// 正在拉取收藏夹内容 @@ -81,7 +82,7 @@ class BiliFavImportNotifier extends _$BiliFavImportNotifier { return const BiliFavImportState.idle(); } - /// 加载用户的 B 站收藏夹列表 + /// 加载用户的 B 站收藏夹列表(包含自己创建的和收藏的他人收藏夹) Future loadFolders() async { ref.keepAlive(); state = const BiliFavImportState.loadingFolders(); @@ -96,8 +97,21 @@ class BiliFavImportNotifier extends _$BiliFavImportNotifier { state = const BiliFavImportState.error('pleaseLoginFirst'); return; } - final folders = await _parseRepo.getFavoriteFolders(mid); - state = BiliFavImportState.foldersLoaded(folders); + + // 并发加载创建的和收藏的收藏夹 + final createdFolders = await _parseRepo.getFavoriteFolders(mid); + final collectedFolders = + await _parseRepo.getCollectedFavoriteFolders(mid); + + AppLogger.info( + '收藏夹加载完成: 创建=${createdFolders.length}, 收藏=${collectedFolders.length}', + tag: 'BiliFavImport', + ); + + state = BiliFavImportState.foldersLoaded( + createdFolders, + collectedFolders, + ); } catch (e) { AppLogger.error('加载收藏夹列表失败', tag: 'BiliFavImport', error: e); state = BiliFavImportState.error(e.toString()); diff --git a/lib/features/playlist/application/bili_fav_import_notifier.freezed.dart b/lib/features/playlist/application/bili_fav_import_notifier.freezed.dart index 0937f20..400c341 100644 --- a/lib/features/playlist/application/bili_fav_import_notifier.freezed.dart +++ b/lib/features/playlist/application/bili_fav_import_notifier.freezed.dart @@ -20,7 +20,9 @@ mixin _$BiliFavImportState { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -36,7 +38,9 @@ mixin _$BiliFavImportState { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -50,7 +54,9 @@ mixin _$BiliFavImportState { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -163,7 +169,9 @@ class _$IdleImpl implements _Idle { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -182,7 +190,9 @@ class _$IdleImpl implements _Idle { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -199,7 +209,9 @@ class _$IdleImpl implements _Idle { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -312,7 +324,9 @@ class _$LoadingFoldersImpl implements _LoadingFolders { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -331,7 +345,9 @@ class _$LoadingFoldersImpl implements _LoadingFolders { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -348,7 +364,9 @@ class _$LoadingFoldersImpl implements _LoadingFolders { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -424,7 +442,9 @@ abstract class _$$FoldersLoadedImplCopyWith<$Res> { _$FoldersLoadedImpl value, $Res Function(_$FoldersLoadedImpl) then) = __$$FoldersLoadedImplCopyWithImpl<$Res>; @useResult - $Res call({List folders}); + $Res call( + {List createdFolders, + List collectedFolders}); } /// @nodoc @@ -440,12 +460,17 @@ class __$$FoldersLoadedImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? folders = null, + Object? createdFolders = null, + Object? collectedFolders = null, }) { return _then(_$FoldersLoadedImpl( - null == folders - ? _value._folders - : folders // ignore: cast_nullable_to_non_nullable + null == createdFolders + ? _value._createdFolders + : createdFolders // ignore: cast_nullable_to_non_nullable + as List, + null == collectedFolders + ? _value._collectedFolders + : collectedFolders // ignore: cast_nullable_to_non_nullable as List, )); } @@ -454,20 +479,31 @@ class __$$FoldersLoadedImplCopyWithImpl<$Res> /// @nodoc class _$FoldersLoadedImpl implements _FoldersLoaded { - const _$FoldersLoadedImpl(final List folders) - : _folders = folders; + const _$FoldersLoadedImpl(final List createdFolders, + final List collectedFolders) + : _createdFolders = createdFolders, + _collectedFolders = collectedFolders; - final List _folders; + final List _createdFolders; @override - List get folders { - if (_folders is EqualUnmodifiableListView) return _folders; + List get createdFolders { + if (_createdFolders is EqualUnmodifiableListView) return _createdFolders; // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_folders); + return EqualUnmodifiableListView(_createdFolders); + } + + final List _collectedFolders; + @override + List get collectedFolders { + if (_collectedFolders is EqualUnmodifiableListView) + return _collectedFolders; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_collectedFolders); } @override String toString() { - return 'BiliFavImportState.foldersLoaded(folders: $folders)'; + return 'BiliFavImportState.foldersLoaded(createdFolders: $createdFolders, collectedFolders: $collectedFolders)'; } @override @@ -475,12 +511,17 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { return identical(this, other) || (other.runtimeType == runtimeType && other is _$FoldersLoadedImpl && - const DeepCollectionEquality().equals(other._folders, _folders)); + const DeepCollectionEquality() + .equals(other._createdFolders, _createdFolders) && + const DeepCollectionEquality() + .equals(other._collectedFolders, _collectedFolders)); } @override - int get hashCode => - Object.hash(runtimeType, const DeepCollectionEquality().hash(_folders)); + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_createdFolders), + const DeepCollectionEquality().hash(_collectedFolders)); /// Create a copy of BiliFavImportState /// with the given fields replaced by the non-null parameter values. @@ -495,7 +536,9 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -506,7 +549,7 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { completed, required TResult Function(String message) error, }) { - return foldersLoaded(folders); + return foldersLoaded(createdFolders, collectedFolders); } @override @@ -514,7 +557,9 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -523,7 +568,7 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { completed, TResult? Function(String message)? error, }) { - return foldersLoaded?.call(folders); + return foldersLoaded?.call(createdFolders, collectedFolders); } @override @@ -531,7 +576,9 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -542,7 +589,7 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { required TResult orElse(), }) { if (foldersLoaded != null) { - return foldersLoaded(folders); + return foldersLoaded(createdFolders, collectedFolders); } return orElse(); } @@ -598,10 +645,11 @@ class _$FoldersLoadedImpl implements _FoldersLoaded { } abstract class _FoldersLoaded implements BiliFavImportState { - const factory _FoldersLoaded(final List folders) = - _$FoldersLoadedImpl; + const factory _FoldersLoaded(final List createdFolders, + final List collectedFolders) = _$FoldersLoadedImpl; - List get folders; + List get createdFolders; + List get collectedFolders; /// Create a copy of BiliFavImportState /// with the given fields replaced by the non-null parameter values. @@ -698,7 +746,9 @@ class _$LoadingItemsImpl implements _LoadingItems { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -717,7 +767,9 @@ class _$LoadingItemsImpl implements _LoadingItems { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -734,7 +786,9 @@ class _$LoadingItemsImpl implements _LoadingItems { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -904,7 +958,9 @@ class _$ItemsLoadedImpl implements _ItemsLoaded { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -923,7 +979,9 @@ class _$ItemsLoadedImpl implements _ItemsLoaded { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -940,7 +998,9 @@ class _$ItemsLoadedImpl implements _ItemsLoaded { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -1099,7 +1159,9 @@ class _$ImportingImpl implements _Importing { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -1118,7 +1180,9 @@ class _$ImportingImpl implements _Importing { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -1135,7 +1199,9 @@ class _$ImportingImpl implements _Importing { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -1337,7 +1403,9 @@ class _$CompletedImpl implements _Completed { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -1356,7 +1424,9 @@ class _$CompletedImpl implements _Completed { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -1373,7 +1443,9 @@ class _$CompletedImpl implements _Completed { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, @@ -1530,7 +1602,9 @@ class _$ErrorImpl implements _Error { TResult when({ required TResult Function() idle, required TResult Function() loadingFolders, - required TResult Function(List folders) foldersLoaded, + required TResult Function(List createdFolders, + List collectedFolders) + foldersLoaded, required TResult Function(String folderName, int fetched, int total) loadingItems, required TResult Function(String folderName, List items) @@ -1549,7 +1623,9 @@ class _$ErrorImpl implements _Error { TResult? whenOrNull({ TResult? Function()? idle, TResult? Function()? loadingFolders, - TResult? Function(List folders)? foldersLoaded, + TResult? Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult? Function(String folderName, int fetched, int total)? loadingItems, TResult? Function(String folderName, List items)? itemsLoaded, TResult? Function(int current, int total)? importing, @@ -1566,7 +1642,9 @@ class _$ErrorImpl implements _Error { TResult maybeWhen({ TResult Function()? idle, TResult Function()? loadingFolders, - TResult Function(List folders)? foldersLoaded, + TResult Function(List createdFolders, + List collectedFolders)? + foldersLoaded, TResult Function(String folderName, int fetched, int total)? loadingItems, TResult Function(String folderName, List items)? itemsLoaded, TResult Function(int current, int total)? importing, diff --git a/lib/features/playlist/application/bili_fav_import_notifier.g.dart b/lib/features/playlist/application/bili_fav_import_notifier.g.dart index e02dd00..34c7f1a 100644 --- a/lib/features/playlist/application/bili_fav_import_notifier.g.dart +++ b/lib/features/playlist/application/bili_fav_import_notifier.g.dart @@ -7,7 +7,7 @@ part of 'bili_fav_import_notifier.dart'; // ************************************************************************** String _$biliFavImportNotifierHash() => - r'02f775156639bfb0bf075deccd5f966525ee5fc4'; + r'0b2b9b4a893a53ba6442058d9eda8575e6aeea34'; /// See also [BiliFavImportNotifier]. @ProviderFor(BiliFavImportNotifier) diff --git a/lib/features/playlist/presentation/widgets/bili_fav_folder_list.dart b/lib/features/playlist/presentation/widgets/bili_fav_folder_list.dart index 8c9229f..8357ef1 100644 --- a/lib/features/playlist/presentation/widgets/bili_fav_folder_list.dart +++ b/lib/features/playlist/presentation/widgets/bili_fav_folder_list.dart @@ -7,19 +7,22 @@ import 'bili_fav_error_view.dart'; /// 收藏夹列表视图(Phase 1)。 /// /// 纯展示组件,通过回调与父级通信。 +/// [showOwnerName] 为 `true` 时会在副标题中显示收藏夹创建者名称。 class BiliFavFolderListView extends StatelessWidget { const BiliFavFolderListView({ super.key, required this.folders, - required this.isLoading, + this.isLoading = false, this.errorMessage, + this.showOwnerName = false, required this.onFolderTapped, required this.onRetry, }); - final List? folders; + final List folders; final bool isLoading; final String? errorMessage; + final bool showOwnerName; final ValueChanged onFolderTapped; final VoidCallback onRetry; @@ -41,8 +44,7 @@ class BiliFavFolderListView extends StatelessWidget { ); } - final list = folders; - if (list == null || list.isEmpty) { + if (folders.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -58,9 +60,9 @@ class BiliFavFolderListView extends StatelessWidget { return ListView.builder( shrinkWrap: true, - itemCount: list.length, + itemCount: folders.length, itemBuilder: (context, index) { - final folder = list[index]; + final folder = folders[index]; return ListTile( leading: const Icon(Icons.folder_outlined), title: Text( @@ -68,6 +70,16 @@ class BiliFavFolderListView extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), + subtitle: showOwnerName && folder.ownerName != null + ? Text( + context.l10n.folderOwnerDesc(folder.ownerName!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ) + : null, trailing: Text( context.l10n.biliFavSongCount(folder.mediaCount), style: context.textTheme.bodySmall?.copyWith( diff --git a/lib/features/playlist/presentation/widgets/bili_fav_import_dialog.dart b/lib/features/playlist/presentation/widgets/bili_fav_import_dialog.dart index 9162694..f0d7448 100644 --- a/lib/features/playlist/presentation/widgets/bili_fav_import_dialog.dart +++ b/lib/features/playlist/presentation/widgets/bili_fav_import_dialog.dart @@ -11,10 +11,13 @@ import 'bili_fav_folder_list.dart'; import 'bili_fav_preview.dart'; import 'bili_fav_progress.dart'; +/// 收藏夹来源类型 +enum _FavSource { created, collected } + /// B 站收藏夹导入一体化对话框。 /// /// 内部管理三个阶段: -/// 1. 收藏夹列表选择 +/// 1. 收藏夹列表选择(支持"我的收藏夹"和"收藏的收藏夹"标签切换) /// 2. 歌曲预览 + 勾选 /// 3. 导入进度 / 结果 /// @@ -52,9 +55,11 @@ class _BiliFavImportDialogState extends ConsumerState { _Phase _phase = _Phase.folderList; // ── 收藏夹列表阶段 ── - List? _folders; + List _createdFolders = []; + List _collectedFolders = []; bool _foldersLoading = true; String? _foldersError; + _FavSource _selectedSource = _FavSource.created; // ── 加载内容阶段 ── String _currentFolderName = ''; @@ -93,6 +98,12 @@ class _BiliFavImportDialogState extends ConsumerState { super.dispose(); } + /// 当前显示的收藏夹列表(根据选中标签) + List get _currentFolders => + _selectedSource == _FavSource.created + ? _createdFolders + : _collectedFolders; + // ── 业务方法 ────────────────────────────────────────────────────────── Future _loadFolders() async { @@ -111,15 +122,26 @@ class _BiliFavImportDialogState extends ConsumerState { state.when( idle: () {}, loadingFolders: () {}, - foldersLoaded: (folders) { + foldersLoaded: (createdFolders, collectedFolders) { AppLogger.info( - '收藏夹列表加载完成,共 ${folders.length} 个', + '收藏夹列表加载完成: 创建=${createdFolders.length}, 收藏=${collectedFolders.length}', tag: 'BiliFavUI', ); if (!mounted) return; setState(() { - _folders = folders; + _createdFolders = createdFolders; + _collectedFolders = collectedFolders; _foldersLoading = false; + // 如果当前选中的来源没有数据且另一个来源有数据,自动切换 + if (_createdFolders.isEmpty && + _collectedFolders.isNotEmpty && + _selectedSource == _FavSource.created) { + _selectedSource = _FavSource.collected; + } else if (_collectedFolders.isEmpty && + _createdFolders.isNotEmpty && + _selectedSource == _FavSource.collected) { + _selectedSource = _FavSource.created; + } }); }, loadingItems: (_, __, ___) {}, @@ -231,7 +253,7 @@ class _BiliFavImportDialogState extends ConsumerState { state.when( idle: () {}, loadingFolders: () {}, - foldersLoaded: (_) {}, + foldersLoaded: (_, __) {}, loadingItems: (_, __, ___) {}, itemsLoaded: (_, __) {}, importing: (current, total) { @@ -320,7 +342,7 @@ class _BiliFavImportDialogState extends ConsumerState { @override Widget build(BuildContext context) { // 监听 notifier 状态变更以更新进度 - ref.listen(biliFavImportNotifierProvider, (_, __) { + ref.listen(biliFavImportNotifierProvider, (_, __) { _listenImportProgress(); }); @@ -406,13 +428,7 @@ class _BiliFavImportDialogState extends ConsumerState { Widget _buildBody(BuildContext context, dynamic l10n) { switch (_phase) { case _Phase.folderList: - return BiliFavFolderListView( - folders: _folders, - isLoading: _foldersLoading, - errorMessage: _foldersError, - onFolderTapped: _onFolderTapped, - onRetry: _loadFolders, - ); + return _buildFolderListView(context, l10n); case _Phase.loadingItems: return _buildLoadingItems(context, l10n); case _Phase.preview: @@ -451,6 +467,102 @@ class _BiliFavImportDialogState extends ConsumerState { } } + // ── Phase 1: 收藏夹列表(带标签切换) ────────────────────────────────── + + Widget _buildFolderListView(BuildContext context, dynamic l10n) { + if (_foldersLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } + + if (_foldersError != null) { + return BiliFavErrorView( + message: _foldersError!, + onRetry: _loadFolders, + ); + } + + final hasCreated = _createdFolders.isNotEmpty; + final hasCollected = _collectedFolders.isNotEmpty; + + if (!hasCreated && !hasCollected) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + context.l10n.favFolderEmpty, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final currentFolders = _currentFolders; + final isEmptyList = currentFolders.isEmpty; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 始终显示标签切换(SegmentedButton) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: SegmentedButton<_FavSource>( + segments: [ + ButtonSegment( + value: _FavSource.created, + label: Text(l10n.myFavFolders), + ), + ButtonSegment( + value: _FavSource.collected, + label: Text(l10n.collectedFavFolders), + ), + ], + selected: {_selectedSource}, + onSelectionChanged: (selected) { + setState(() { + _selectedSource = selected.first; + }); + }, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + textStyle: WidgetStatePropertyAll( + context.textTheme.labelMedium, + ), + ), + ), + ), + + const SizedBox(height: 4), + + // 收藏夹列表或空态 + Expanded( + child: isEmptyList + ? Center( + child: Text( + context.l10n.favFolderEmpty, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + ) + : BiliFavFolderListView( + folders: currentFolders, + isLoading: false, + showOwnerName: _selectedSource == _FavSource.collected, + onFolderTapped: _onFolderTapped, + onRetry: _loadFolders, + ), + ), + ], + ); + } + // ── Phase 1.5: 加载收藏夹内容 ────────────────────────────────────────── Widget _buildLoadingItems(BuildContext context, dynamic l10n) { diff --git a/lib/features/search_and_parse/data/parse_repository.dart b/lib/features/search_and_parse/data/parse_repository.dart index 0195346..5595919 100644 --- a/lib/features/search_and_parse/data/parse_repository.dart +++ b/lib/features/search_and_parse/data/parse_repository.dart @@ -60,11 +60,21 @@ abstract class ParseRepository { // ── B 站收藏夹 ─────────────────────────────────────────────────────── - /// 获取用户的所有收藏夹列表(需要登录)。 + /// 获取用户创建的所有收藏夹列表(需要登录)。 /// /// [mid] 为用户 UID,从已登录的 User 对象获取。 Future> getFavoriteFolders(int mid); + /// 获取用户收藏的他人收藏夹列表(需要登录)。 + /// + /// [mid] 为当前登录用户的 UID。 + /// [page] 为页码(1-based),[pageSize] 为每页数量(默认 20)。 + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }); + /// 获取收藏夹中的所有视频(自动分页拉取全量)。 /// /// [mediaId] 为收藏夹 ID([BiliFavFolder.id])。 diff --git a/lib/features/search_and_parse/data/parse_repository_impl.dart b/lib/features/search_and_parse/data/parse_repository_impl.dart index b1bee58..b43ffb8 100644 --- a/lib/features/search_and_parse/data/parse_repository_impl.dart +++ b/lib/features/search_and_parse/data/parse_repository_impl.dart @@ -422,6 +422,50 @@ class ParseRepositoryImpl implements ParseRepository { .toList(); } + @override + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }) async { + final response = await _biliDio.get( + '/x/v3/fav/folder/collected/list', + queryParameters: { + 'up_mid': mid, + 'pn': page, + 'ps': pageSize, + }, + ); + final code = _asInt(response.data['code']) ?? 0; + if (code != 0) { + AppLogger.warning( + 'getCollectedFavoriteFolders 返回非零 code: $code, ' + 'message: ${response.data['message']}', + tag: 'Parse', + ); + return []; + } + final data = response.data['data']; + if (data == null) { + AppLogger.warning( + 'getCollectedFavoriteFolders: data is null, ' + 'full response: ${response.data}', + tag: 'Parse', + ); + return []; + } + final list = data['list'] as List? ?? []; + return list + .map((item) => BiliFavFolder( + id: item['id'] as int, + title: item['title'] as String, + mediaCount: item['media_count'] as int, + ownerName: + (item['upper'] as Map?)?['name'] as String?, + )) + .toList(); + } + @override Future> getFavoriteFolderItems( int mediaId, { diff --git a/lib/features/search_and_parse/domain/models/bili_fav_folder.dart b/lib/features/search_and_parse/domain/models/bili_fav_folder.dart index 920e72d..64b7323 100644 --- a/lib/features/search_and_parse/domain/models/bili_fav_folder.dart +++ b/lib/features/search_and_parse/domain/models/bili_fav_folder.dart @@ -2,9 +2,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'bili_fav_folder.freezed.dart'; -/// B 站用户收藏夹信息。 +/// B 站收藏夹信息。 /// -/// 由 `/x/v3/fav/folder/created/list-all` API 返回。 +/// 由以下 API 返回: +/// - `/x/v3/fav/folder/created/list-all`(自己的收藏夹,ownerName 为 null) +/// - `/x/v3/fav/folder/collected/list`(收藏的收藏夹,ownerName 为 UP 主名称) @freezed class BiliFavFolder with _$BiliFavFolder { const factory BiliFavFolder({ @@ -16,5 +18,8 @@ class BiliFavFolder with _$BiliFavFolder { /// 收藏夹内视频数量 required int mediaCount, + + /// 收藏夹创建者名称(仅对收藏的收藏夹有效,自己的为 null) + String? ownerName, }) = _BiliFavFolder; } diff --git a/lib/features/search_and_parse/domain/models/bili_fav_folder.freezed.dart b/lib/features/search_and_parse/domain/models/bili_fav_folder.freezed.dart index 81f5677..e52edb7 100644 --- a/lib/features/search_and_parse/domain/models/bili_fav_folder.freezed.dart +++ b/lib/features/search_and_parse/domain/models/bili_fav_folder.freezed.dart @@ -25,6 +25,9 @@ mixin _$BiliFavFolder { /// 收藏夹内视频数量 int get mediaCount => throw _privateConstructorUsedError; + /// 收藏夹创建者名称(仅对收藏的收藏夹有效,自己的为 null) + String? get ownerName => throw _privateConstructorUsedError; + /// Create a copy of BiliFavFolder /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -38,7 +41,7 @@ abstract class $BiliFavFolderCopyWith<$Res> { BiliFavFolder value, $Res Function(BiliFavFolder) then) = _$BiliFavFolderCopyWithImpl<$Res, BiliFavFolder>; @useResult - $Res call({int id, String title, int mediaCount}); + $Res call({int id, String title, int mediaCount, String? ownerName}); } /// @nodoc @@ -59,6 +62,7 @@ class _$BiliFavFolderCopyWithImpl<$Res, $Val extends BiliFavFolder> Object? id = null, Object? title = null, Object? mediaCount = null, + Object? ownerName = freezed, }) { return _then(_value.copyWith( id: null == id @@ -73,6 +77,10 @@ class _$BiliFavFolderCopyWithImpl<$Res, $Val extends BiliFavFolder> ? _value.mediaCount : mediaCount // ignore: cast_nullable_to_non_nullable as int, + ownerName: freezed == ownerName + ? _value.ownerName + : ownerName // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } } @@ -85,7 +93,7 @@ abstract class _$$BiliFavFolderImplCopyWith<$Res> __$$BiliFavFolderImplCopyWithImpl<$Res>; @override @useResult - $Res call({int id, String title, int mediaCount}); + $Res call({int id, String title, int mediaCount, String? ownerName}); } /// @nodoc @@ -104,6 +112,7 @@ class __$$BiliFavFolderImplCopyWithImpl<$Res> Object? id = null, Object? title = null, Object? mediaCount = null, + Object? ownerName = freezed, }) { return _then(_$BiliFavFolderImpl( id: null == id @@ -118,6 +127,10 @@ class __$$BiliFavFolderImplCopyWithImpl<$Res> ? _value.mediaCount : mediaCount // ignore: cast_nullable_to_non_nullable as int, + ownerName: freezed == ownerName + ? _value.ownerName + : ownerName // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -126,7 +139,10 @@ class __$$BiliFavFolderImplCopyWithImpl<$Res> class _$BiliFavFolderImpl implements _BiliFavFolder { const _$BiliFavFolderImpl( - {required this.id, required this.title, required this.mediaCount}); + {required this.id, + required this.title, + required this.mediaCount, + this.ownerName}); /// 收藏夹 media_id(用于查询收藏夹内容) @override @@ -140,9 +156,13 @@ class _$BiliFavFolderImpl implements _BiliFavFolder { @override final int mediaCount; + /// 收藏夹创建者名称(仅对收藏的收藏夹有效,自己的为 null) + @override + final String? ownerName; + @override String toString() { - return 'BiliFavFolder(id: $id, title: $title, mediaCount: $mediaCount)'; + return 'BiliFavFolder(id: $id, title: $title, mediaCount: $mediaCount, ownerName: $ownerName)'; } @override @@ -153,11 +173,14 @@ class _$BiliFavFolderImpl implements _BiliFavFolder { (identical(other.id, id) || other.id == id) && (identical(other.title, title) || other.title == title) && (identical(other.mediaCount, mediaCount) || - other.mediaCount == mediaCount)); + other.mediaCount == mediaCount) && + (identical(other.ownerName, ownerName) || + other.ownerName == ownerName)); } @override - int get hashCode => Object.hash(runtimeType, id, title, mediaCount); + int get hashCode => + Object.hash(runtimeType, id, title, mediaCount, ownerName); /// Create a copy of BiliFavFolder /// with the given fields replaced by the non-null parameter values. @@ -172,7 +195,8 @@ abstract class _BiliFavFolder implements BiliFavFolder { const factory _BiliFavFolder( {required final int id, required final String title, - required final int mediaCount}) = _$BiliFavFolderImpl; + required final int mediaCount, + final String? ownerName}) = _$BiliFavFolderImpl; /// 收藏夹 media_id(用于查询收藏夹内容) @override @@ -186,6 +210,10 @@ abstract class _BiliFavFolder implements BiliFavFolder { @override int get mediaCount; + /// 收藏夹创建者名称(仅对收藏的收藏夹有效,自己的为 null) + @override + String? get ownerName; + /// Create a copy of BiliFavFolder /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/features/subtitle/application/subtitle_notifier.g.dart b/lib/features/subtitle/application/subtitle_notifier.g.dart index 03eb36a..ee4f6f2 100644 --- a/lib/features/subtitle/application/subtitle_notifier.g.dart +++ b/lib/features/subtitle/application/subtitle_notifier.g.dart @@ -6,7 +6,7 @@ part of 'subtitle_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$subtitleNotifierHash() => r'51fddd9c3530440e753ed2ba9ac87c39fa6c78f3'; +String _$subtitleNotifierHash() => r'9a614944d0472ee9534d5cae569e784db1a8dbf0'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5198790..bf85c09 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -353,6 +353,14 @@ "createPlaylistManualDesc": "Enter a name to create an empty playlist", "importFromBiliFav": "Import from Bilibili Favorites", "importFromBiliFavDesc": "Sign in to import from your favorite folders", + "myFavFolders": "My Folders", + "collectedFavFolders": "Collected Folders", + "folderOwnerDesc": "by {owner}", + "@folderOwnerDesc": { + "placeholders": { + "owner": { "type": "String" } + } + }, "selectFavFolder": "Select Favorite Folder", "loadingFavItems": "Fetching favorites ({fetched}/{total})", "@loadingFavItems": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ce6cdea..334d4c8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -345,6 +345,14 @@ "createPlaylistManualDesc": "输入歌单名称创建空歌单", "importFromBiliFav": "从 B 站收藏夹导入", "importFromBiliFavDesc": "登录后选择收藏夹一键导入", + "myFavFolders": "我的收藏夹", + "collectedFavFolders": "收藏的收藏夹", + "folderOwnerDesc": "来自 {owner}", + "@folderOwnerDesc": { + "placeholders": { + "owner": { "type": "String" } + } + }, "selectFavFolder": "选择收藏夹", "loadingFavItems": "正在拉取收藏夹内容 ({fetched}/{total})", "@loadingFavItems": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b702d6e..b046c07 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1649,6 +1649,24 @@ abstract class AppLocalizations { /// **'Sign in to import from your favorite folders'** String get importFromBiliFavDesc; + /// No description provided for @myFavFolders. + /// + /// In en, this message translates to: + /// **'My Folders'** + String get myFavFolders; + + /// No description provided for @collectedFavFolders. + /// + /// In en, this message translates to: + /// **'Collected Folders'** + String get collectedFavFolders; + + /// No description provided for @folderOwnerDesc. + /// + /// In en, this message translates to: + /// **'by {owner}'** + String folderOwnerDesc(String owner); + /// No description provided for @selectFavFolder. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index dc5f82d..f829490 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -817,6 +817,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importFromBiliFavDesc => 'Sign in to import from your favorite folders'; + @override + String get myFavFolders => 'My Folders'; + + @override + String get collectedFavFolders => 'Collected Folders'; + + @override + String folderOwnerDesc(String owner) { + return 'by $owner'; + } + @override String get selectFavFolder => 'Select Favorite Folder'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index a11cc7e..84910c2 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -817,6 +817,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get importFromBiliFavDesc => '登录后选择收藏夹一键导入'; + @override + String get myFavFolders => '我的收藏夹'; + + @override + String get collectedFavFolders => '收藏的收藏夹'; + + @override + String folderOwnerDesc(String owner) { + return '来自 $owner'; + } + @override String get selectFavFolder => '选择收藏夹'; diff --git a/test/features/player/application/player_notifier_test.dart b/test/features/player/application/player_notifier_test.dart index e6da018..c11b9b1 100644 --- a/test/features/player/application/player_notifier_test.dart +++ b/test/features/player/application/player_notifier_test.dart @@ -528,6 +528,15 @@ class _FakeParseRepository implements ParseRepository { throw UnimplementedError(); } + @override + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }) { + throw UnimplementedError(); + } + @override Future> getFavoriteFolderItems( int mediaId, { diff --git a/test/features/player/presentation/player_bar_favorite_test.dart b/test/features/player/presentation/player_bar_favorite_test.dart index 9336918..45658ad 100644 --- a/test/features/player/presentation/player_bar_favorite_test.dart +++ b/test/features/player/presentation/player_bar_favorite_test.dart @@ -438,6 +438,15 @@ class _FakeParseRepository implements ParseRepository { throw UnimplementedError(); } + @override + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }) { + throw UnimplementedError(); + } + @override Future> getFavoriteFolderItems( int mediaId, { diff --git a/test/features/search_and_parse/data/parse_repository_impl_test.dart b/test/features/search_and_parse/data/parse_repository_impl_test.dart index db6e7d9..7f63898 100644 --- a/test/features/search_and_parse/data/parse_repository_impl_test.dart +++ b/test/features/search_and_parse/data/parse_repository_impl_test.dart @@ -141,6 +141,97 @@ void main() { expect(tags, isEmpty); }); }); + + group('ParseRepositoryImpl.getCollectedFavoriteFolders', () { + test('返回收藏的收藏夹列表(含 owner 信息)', () async { + adapter.register( + '/x/v3/fav/folder/collected/list', + body: { + 'code': 0, + 'message': '0', + 'data': { + 'list': [ + { + 'id': 1001, + 'title': '音乐收藏夹', + 'media_count': 42, + 'upper': { + 'mid': 888, + 'name': 'UP主A', + 'face': 'https://example.com/face.jpg', + }, + }, + { + 'id': 1002, + 'title': '视频收藏夹', + 'media_count': 10, + 'upper': { + 'mid': 999, + 'name': 'UP主B', + 'face': 'https://example.com/face2.jpg', + }, + }, + ], + }, + }, + ); + + final folders = await repository.getCollectedFavoriteFolders(888); + + expect(folders, hasLength(2)); + + expect(folders[0].id, 1001); + expect(folders[0].title, '音乐收藏夹'); + expect(folders[0].mediaCount, 42); + expect(folders[0].ownerName, 'UP主A'); + + expect(folders[1].id, 1002); + expect(folders[1].title, '视频收藏夹'); + expect(folders[1].mediaCount, 10); + expect(folders[1].ownerName, 'UP主B'); + }); + + test('列表为空时返回空数组', () async { + adapter.register( + '/x/v3/fav/folder/collected/list', + body: { + 'code': 0, + 'message': '0', + 'data': { + 'list': [], + }, + }, + ); + + final folders = await repository.getCollectedFavoriteFolders(888); + + expect(folders, isEmpty); + }); + + test('不包含 upper 信息时 ownerName 为 null', () async { + adapter.register( + '/x/v3/fav/folder/collected/list', + body: { + 'code': 0, + 'message': '0', + 'data': { + 'list': [ + { + 'id': 1001, + 'title': '无主收藏夹', + 'media_count': 5, + }, + ], + }, + }, + ); + + final folders = await repository.getCollectedFavoriteFolders(888); + + expect(folders, hasLength(1)); + expect(folders[0].ownerName, isNull); + }); + }); } class _QueuedHttpClientAdapter implements HttpClientAdapter { diff --git a/test/features/share/data/share_repository_impl_test.dart b/test/features/share/data/share_repository_impl_test.dart index 0f16ad1..4c6a0b1 100644 --- a/test/features/share/data/share_repository_impl_test.dart +++ b/test/features/share/data/share_repository_impl_test.dart @@ -74,6 +74,15 @@ class MockParseRepository implements ParseRepository { throw UnimplementedError('测试中不需要'); } + @override + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }) async { + throw UnimplementedError('测试中不需要'); + } + @override Future> getFavoriteFolderItems( int mediaId, { diff --git a/test/shared/widgets/responsive_scaffold_test.dart b/test/shared/widgets/responsive_scaffold_test.dart index 77972ae..a9fb505 100644 --- a/test/shared/widgets/responsive_scaffold_test.dart +++ b/test/shared/widgets/responsive_scaffold_test.dart @@ -314,6 +314,15 @@ class _FakeParseRepository implements ParseRepository { throw UnimplementedError(); } + @override + Future> getCollectedFavoriteFolders( + int mid, { + int page = 1, + int pageSize = 20, + }) { + throw UnimplementedError(); + } + @override Future> getFavoriteFolderItems( int mediaId, {