feat: add media categories and folder selection

This commit is contained in:
ngfchl
2026-07-18 11:05:29 +08:00
parent acab1da344
commit f4306e648e
5 changed files with 951 additions and 210 deletions
+1
View File
@@ -14,6 +14,7 @@ class StorageKeys {
static const String tmdbProxyPort = 'guangya.tmdbProxyPort';
static const String mediaLibraries = 'guangya.mediaLibraries';
static const String mediaLibraryItems = 'guangya.mediaLibraryItems';
static const String mediaCategoryRules = 'guangya.mediaCategoryRules';
}
class StorageManager {
+110
View File
@@ -15,6 +15,116 @@ extension TMDBMediaKindX on TMDBMediaKind {
}
}
class MediaCategoryRule {
final String id;
final String name;
final TMDBMediaKind mediaKind;
final List<String> languages;
final bool isFallback;
const MediaCategoryRule({
required this.id,
required this.name,
required this.mediaKind,
this.languages = const [],
this.isFallback = false,
});
MediaCategoryRule copyWith({
String? name,
TMDBMediaKind? mediaKind,
List<String>? languages,
bool? isFallback,
}) {
return MediaCategoryRule(
id: id,
name: name ?? this.name,
mediaKind: mediaKind ?? this.mediaKind,
languages: languages ?? this.languages,
isFallback: isFallback ?? this.isFallback,
);
}
factory MediaCategoryRule.fromJson(Map<String, dynamic> json) {
return MediaCategoryRule(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
name: json['name']?.toString() ?? '未命名分类',
mediaKind: TMDBMediaKind.values.firstWhere(
(kind) => kind.name == json['mediaKind']?.toString(),
orElse: () => TMDBMediaKind.movie,
),
languages:
(json['languages'] as List?)
?.map((value) => value.toString().trim())
.where((value) => value.isNotEmpty)
.toList() ??
const [],
isFallback: json['isFallback'] == true,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'mediaKind': mediaKind.name,
'languages': languages,
'isFallback': isFallback,
};
static List<MediaCategoryRule> presets() => const [
MediaCategoryRule(
id: 'preset-movie-cn',
name: '国产电影',
mediaKind: TMDBMediaKind.movie,
languages: ['zh', 'cn', 'yue'],
),
MediaCategoryRule(
id: 'preset-movie-jpkr',
name: '日韩电影',
mediaKind: TMDBMediaKind.movie,
languages: ['ja', 'ko', 'th'],
),
MediaCategoryRule(
id: 'preset-movie-west',
name: '欧美电影',
mediaKind: TMDBMediaKind.movie,
languages: ['en'],
),
MediaCategoryRule(
id: 'preset-movie-other',
name: '其他电影',
mediaKind: TMDBMediaKind.movie,
isFallback: true,
),
MediaCategoryRule(
id: 'preset-tv-cn',
name: '国产剧集',
mediaKind: TMDBMediaKind.tv,
languages: ['zh', 'cn', 'yue'],
),
MediaCategoryRule(
id: 'preset-tv-jpkr',
name: '日韩剧集',
mediaKind: TMDBMediaKind.tv,
languages: ['ja', 'ko', 'th'],
),
MediaCategoryRule(
id: 'preset-tv-west',
name: '欧美剧集',
mediaKind: TMDBMediaKind.tv,
languages: ['en'],
),
MediaCategoryRule(
id: 'preset-tv-other',
name: '其他剧集',
mediaKind: TMDBMediaKind.tv,
isFallback: true,
),
];
}
enum MediaLibraryKind { movies, series, mixed }
extension MediaLibraryKindX on MediaLibraryKind {
+410 -92
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/storage/storage_manager.dart';
import '../models/cloud_file.dart';
import '../models/media_library.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
@@ -498,103 +499,19 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) {
final fileState = ref.read(fileProvider);
final currentRootID = fileState.folderPath.isEmpty
? null
: fileState.folderPath.last.id;
final currentPath = fileState.folderPath.isEmpty
? '云盘根目录'
: fileState.folderPath.map((file) => file.name).join(' / ');
final nameController = TextEditingController(
text: fileState.folderPath.isEmpty
? '我的影视库'
: fileState.folderPath.last.name,
);
final minSizeController = TextEditingController(text: '50');
var kind = MediaLibraryKind.mixed;
var recursive = true;
showShadDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setDialogState) => ShadDialog(
title: const Text('创建媒体库'),
description: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('来源:$currentPath'),
),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: () {
ref
.read(mediaLibraryProvider.notifier)
.createLibrary(
name: nameController.text,
rootID: currentRootID,
rootPath: currentPath,
kind: kind,
recursive: recursive,
minimumSizeMB:
int.tryParse(minSizeController.text.trim()) ?? 50,
);
Navigator.of(ctx).pop();
},
child: const Text('创建'),
),
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ShadInput(
controller: nameController,
placeholder: const Text('媒体库名称'),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ShadSelect<MediaLibraryKind>(
initialValue: kind,
placeholder: const Text('媒体类型'),
selectedOptionBuilder: (context, value) =>
Text(value.title),
options: [
for (final value in MediaLibraryKind.values)
ShadOption(value: value, child: Text(value.title)),
],
onChanged: (value) {
if (value != null) {
setDialogState(() => kind = value);
}
},
),
),
const SizedBox(width: 10),
SizedBox(
width: 120,
child: ShadInput(
controller: minSizeController,
keyboardType: TextInputType.number,
placeholder: const Text('最小 MB'),
),
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ShadCheckbox(
value: recursive,
label: const Text('递归扫描子目录'),
onChanged: (value) => setDialogState(() => recursive = value),
),
),
],
),
),
builder: (ctx) => _CreateMediaLibraryDialog(
initialRootID: fileState.folderPath.isEmpty
? null
: fileState.folderPath.last.id,
initialPath: currentPath,
initialName: fileState.folderPath.isEmpty
? '我的影视库'
: fileState.folderPath.last.name,
),
);
}
@@ -644,6 +561,407 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
}
}
class _CreateMediaLibraryDialog extends ConsumerStatefulWidget {
final String? initialRootID;
final String initialPath;
final String initialName;
const _CreateMediaLibraryDialog({
required this.initialRootID,
required this.initialPath,
required this.initialName,
});
@override
ConsumerState<_CreateMediaLibraryDialog> createState() =>
_CreateMediaLibraryDialogState();
}
class _CreateMediaLibraryDialogState
extends ConsumerState<_CreateMediaLibraryDialog> {
late final TextEditingController _nameController;
final _minSizeController = TextEditingController(text: '50');
MediaLibraryKind _kind = MediaLibraryKind.mixed;
bool _recursive = true;
bool _isBrowsing = false;
bool _isLoadingFolders = false;
String? _folderError;
late String? _rootID;
late String _rootPath;
final _browserPath = <CloudFile>[];
var _folders = <CloudFile>[];
@override
void initState() {
super.initState();
_rootID = widget.initialRootID;
_rootPath = widget.initialPath;
_nameController = TextEditingController(text: widget.initialName);
}
@override
void dispose() {
_nameController.dispose();
_minSizeController.dispose();
super.dispose();
}
String get _browserLocation => _browserPath.isEmpty
? '云盘根目录'
: _browserPath.map((folder) => folder.name).join(' / ');
String? get _browserFolderID =>
_browserPath.isEmpty ? null : _browserPath.last.id;
Future<void> _startBrowsing() async {
setState(() {
_isBrowsing = true;
_browserPath.clear();
_folders = [];
});
await _loadFolders();
}
Future<void> _loadFolders() async {
setState(() {
_isLoadingFolders = true;
_folderError = null;
});
try {
final response = await ref
.read(authProvider.notifier)
.api
.fsFiles(parentID: _browserFolderID, pageSize: 1000);
if (mounted) {
setState(() {
_folders =
_extractFiles(response).where((file) => file.isDirectory).toList()
..sort(
(a, b) =>
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
});
}
} catch (error) {
if (mounted) setState(() => _folderError = error.toString());
} finally {
if (mounted) setState(() => _isLoadingFolders = false);
}
}
List<CloudFile> _extractFiles(Map<String, dynamic> value) {
final files = <CloudFile>[];
final ids = <String>{};
void visit(dynamic node) {
if (node is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(node));
if (ids.add(file.id)) files.add(file);
} catch (_) {}
for (final child in node.values) {
visit(child);
}
} else if (node is List) {
for (final child in node) {
visit(child);
}
}
}
visit(value);
return files;
}
void _useBrowserFolder() {
setState(() {
_rootID = _browserFolderID;
_rootPath = _browserLocation;
if (_nameController.text.trim().isEmpty ||
_nameController.text == widget.initialName) {
_nameController.text = _browserPath.isEmpty
? '我的影视库'
: _browserPath.last.name;
}
_isBrowsing = false;
});
}
void _create(BuildContext context) {
ref
.read(mediaLibraryProvider.notifier)
.createLibrary(
name: _nameController.text,
rootID: _rootID,
rootPath: _rootPath,
kind: _kind,
recursive: _recursive,
minimumSizeMB: int.tryParse(_minSizeController.text.trim()) ?? 50,
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadDialog(
title: Text(_isBrowsing ? '选择云盘文件夹' : '创建媒体库'),
description: Text(
_isBrowsing ? '进入目标目录后,选择该目录作为媒体库来源。' : '媒体库会从指定目录扫描视频文件。',
),
actions: _isBrowsing
? [
ShadButton.outline(
onPressed: () => setState(() => _isBrowsing = false),
child: const Text('返回设置'),
),
ShadButton(
onPressed: _useBrowserFolder,
leading: const Icon(Icons.check_rounded, size: 16),
child: const Text('使用此目录'),
),
]
: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: () => _create(context),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('创建媒体库'),
),
],
child: _isBrowsing ? _folderBrowser(cs) : _form(cs),
);
}
Widget _form(ShadColorScheme cs) {
return SizedBox(
width: 520,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: Icon(Icons.folder_rounded, color: cs.primary),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'媒体来源',
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
),
),
const SizedBox(height: 2),
Text(
_rootPath,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
],
),
),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _startBrowsing,
leading: const Icon(Icons.folder_open_rounded, size: 15),
child: const Text('浏览'),
),
],
),
),
const SizedBox(height: 14),
ShadInput(
controller: _nameController,
placeholder: const Text('媒体库名称'),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ShadSelect<MediaLibraryKind>(
initialValue: _kind,
placeholder: const Text('媒体类型'),
selectedOptionBuilder: (context, value) => Text(value.title),
options: [
for (final value in MediaLibraryKind.values)
ShadOption(value: value, child: Text(value.title)),
],
onChanged: (value) {
if (value != null) setState(() => _kind = value);
},
),
),
const SizedBox(width: 10),
SizedBox(
width: 132,
child: ShadInput(
controller: _minSizeController,
keyboardType: TextInputType.number,
placeholder: const Text('最小 MB'),
),
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ShadCheckbox(
value: _recursive,
label: const Text('递归扫描所有子目录'),
sublabel: const Text('关闭后仅扫描当前目录中的视频文件'),
onChanged: (value) => setState(() => _recursive = value),
),
),
],
),
);
}
Widget _folderBrowser(ShadColorScheme cs) {
return SizedBox(
width: 560,
height: 390,
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(7),
),
child: Row(
children: [
ShadTooltip(
builder: (_) => const Text('返回上级目录'),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: _browserPath.isEmpty
? null
: () {
setState(() => _browserPath.removeLast());
_loadFolders();
},
child: const Icon(Icons.arrow_back_rounded, size: 16),
),
),
const SizedBox(width: 6),
Icon(Icons.folder_rounded, size: 16, color: cs.primary),
const SizedBox(width: 6),
Expanded(
child: Text(
_browserLocation,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: cs.foreground),
),
),
ShadTooltip(
builder: (_) => const Text('刷新目录'),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: _isLoadingFolders ? null : _loadFolders,
child: const Icon(Icons.refresh_rounded, size: 16),
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: _isLoadingFolders
? const Center(
child: SizedBox(width: 220, child: ShadProgress()),
)
: _folderError != null
? Center(
child: Text(
_folderError!,
textAlign: TextAlign.center,
style: TextStyle(color: cs.destructive),
),
)
: _folders.isEmpty
? Center(
child: Text(
'此目录没有子文件夹',
style: TextStyle(color: cs.mutedForeground),
),
)
: ListView.separated(
itemCount: _folders.length,
separatorBuilder: (_, _) =>
Divider(height: 1, color: cs.border),
itemBuilder: (context, index) {
final folder = _folders[index];
return InkWell(
onTap: () {
setState(() => _browserPath.add(folder));
_loadFolders();
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 11,
),
child: Row(
children: [
Icon(
Icons.folder_rounded,
size: 19,
color: cs.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
folder.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.chevron_right_rounded,
size: 18,
color: cs.mutedForeground,
),
],
),
),
);
},
),
),
],
),
);
}
}
class _LibraryRow extends StatelessWidget {
final MediaLibraryDefinition library;
final bool selected;
+39 -117
View File
@@ -576,7 +576,7 @@ class _MediaSidebar extends ConsumerWidget {
icon: Icons.category_rounded,
label: '分类管理',
selected: false,
onTap: () => onTool(WorkspaceTool.tmdb),
onTap: () => onTool(WorkspaceTool.categories),
),
],
),
@@ -782,7 +782,6 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> {
state: state,
),
),
_CloudStatusBar(state: state),
],
),
),
@@ -909,67 +908,12 @@ class _ToolbarSegment extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OS26Glass(
radius: 10,
opacity: 0.36,
padding: const EdgeInsets.all(2),
child: Row(
children: [
_ToolbarSegmentButton(
icon: Icons.view_agenda_rounded,
selected: value == _PaneLayoutMode.single,
tooltip: '单面板',
onTap: () => onChanged(_PaneLayoutMode.single),
),
_ToolbarSegmentButton(
icon: Icons.view_column_rounded,
selected: value == _PaneLayoutMode.dual,
tooltip: '双面板',
onTap: () => onChanged(_PaneLayoutMode.dual),
),
],
),
);
}
}
class _ToolbarSegmentButton extends StatelessWidget {
final IconData icon;
final bool selected;
final String tooltip;
final VoidCallback onTap;
const _ToolbarSegmentButton({
required this.icon,
required this.selected,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadTooltip(
builder: (_) => Text(tooltip),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Container(
width: 32,
height: 28,
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.68)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 17,
color: selected ? cs.primary : cs.mutedForeground,
),
),
),
final isDual = value == _PaneLayoutMode.dual;
return _ToolbarButton(
icon: isDual ? Icons.view_agenda_rounded : Icons.view_column_rounded,
label: isDual ? '切换单面板' : '切换双面板',
onTap: () =>
onChanged(isDual ? _PaneLayoutMode.single : _PaneLayoutMode.dual),
);
}
}
@@ -1068,6 +1012,8 @@ class _PrimaryFilePane extends ConsumerWidget {
currentPage: state.currentPage,
pageSize: state.pageSize,
totalPages: state.totalPages,
fileCount: files.where((file) => !file.isDirectory).length,
folderCount: files.where((file) => file.isDirectory).length,
onPreviousPage: state.currentPage == 0 ? null : notifier.prevPage,
onNextPage: state.currentPage >= state.totalPages - 1
? null
@@ -1238,6 +1184,8 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> {
currentPage: _page,
pageSize: _pageSize,
totalPages: _totalPages,
fileCount: _files.where((file) => !file.isDirectory).length,
folderCount: _files.where((file) => file.isDirectory).length,
onPreviousPage: _page == 0
? null
: () {
@@ -1403,6 +1351,8 @@ class _PanePagination extends StatelessWidget {
final int currentPage;
final int pageSize;
final int totalPages;
final int fileCount;
final int folderCount;
final VoidCallback? onPreviousPage;
final VoidCallback? onNextPage;
final ValueChanged<int>? onPageSizeChanged;
@@ -1411,6 +1361,8 @@ class _PanePagination extends StatelessWidget {
required this.currentPage,
required this.pageSize,
required this.totalPages,
required this.fileCount,
required this.folderCount,
this.onPreviousPage,
this.onNextPage,
this.onPageSizeChanged,
@@ -1429,6 +1381,24 @@ class _PanePagination extends StatelessWidget {
),
child: Row(
children: [
Icon(
Icons.insert_drive_file_rounded,
size: 14,
color: cs.mutedForeground,
),
const SizedBox(width: 5),
Text(
'本页文件 $fileCount',
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
const SizedBox(width: 12),
Icon(Icons.folder_rounded, size: 14, color: cs.mutedForeground),
const SizedBox(width: 5),
Text(
'本页文件夹 $folderCount',
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
const SizedBox(width: 14),
Text(
'${currentPage + 1} / ${totalPages.clamp(1, 1 << 31)}',
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
@@ -1518,6 +1488,8 @@ class _FilePaneFrame extends StatelessWidget {
final int currentPage;
final int pageSize;
final int totalPages;
final int fileCount;
final int folderCount;
final VoidCallback? onPreviousPage;
final VoidCallback? onNextPage;
final ValueChanged<int>? onPageSizeChanged;
@@ -1538,6 +1510,8 @@ class _FilePaneFrame extends StatelessWidget {
this.currentPage = 0,
this.pageSize = 50,
this.totalPages = 1,
this.fileCount = 0,
this.folderCount = 0,
this.onPreviousPage,
this.onNextPage,
this.onPageSizeChanged,
@@ -1617,6 +1591,8 @@ class _FilePaneFrame extends StatelessWidget {
currentPage: currentPage,
pageSize: pageSize,
totalPages: totalPages,
fileCount: fileCount,
folderCount: folderCount,
onPreviousPage: onPreviousPage,
onNextPage: onNextPage,
onPageSizeChanged: onPageSizeChanged,
@@ -1826,57 +1802,3 @@ class _FilePaneHeader extends StatelessWidget {
);
}
}
class _CloudStatusBar extends StatelessWidget {
final FileState state;
const _CloudStatusBar({required this.state});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
height: 40,
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.insert_drive_file_rounded,
size: 15,
color: cs.mutedForeground,
),
const SizedBox(width: 6),
Text(
'本页文件 ${state.files.where((file) => !file.isDirectory).length}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
const SizedBox(width: 14),
Icon(Icons.folder_rounded, size: 15, color: cs.mutedForeground),
const SizedBox(width: 6),
Text(
'本页文件夹 ${state.files.where((file) => file.isDirectory).length}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
const Spacer(),
if (state.errorMessage != null)
Text(
state.errorMessage!,
style: TextStyle(fontSize: 12, color: cs.destructive),
overflow: TextOverflow.ellipsis,
)
else if (state.statusMessage != null)
Text(
state.statusMessage!,
style: TextStyle(fontSize: 12, color: cs.primary),
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
+391 -1
View File
@@ -4,12 +4,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/storage/storage_manager.dart';
import '../models/cloud_file.dart';
import '../models/media_library.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
import 'media_library_page.dart';
enum WorkspaceTool { scan, rename, fastTransfer, tmdb }
enum WorkspaceTool { scan, rename, fastTransfer, tmdb, categories }
extension WorkspaceToolDetails on WorkspaceTool {
String get title {
@@ -22,6 +24,8 @@ extension WorkspaceToolDetails on WorkspaceTool {
return '秒传工具';
case WorkspaceTool.tmdb:
return 'TMDB 整理';
case WorkspaceTool.categories:
return '分类管理';
}
}
@@ -35,6 +39,8 @@ extension WorkspaceToolDetails on WorkspaceTool {
return Icons.bolt_rounded;
case WorkspaceTool.tmdb:
return Icons.auto_fix_high_rounded;
case WorkspaceTool.categories:
return Icons.grid_view_rounded;
}
}
}
@@ -56,6 +62,7 @@ class WorkspaceToolsPage extends StatelessWidget {
WorkspaceTool.rename => const _BatchRenameTool(),
WorkspaceTool.fastTransfer => const _FastTransferTool(),
WorkspaceTool.tmdb => const MediaLibraryPage(showLibrarySidebar: true),
WorkspaceTool.categories => const _CategoryManagementTool(),
};
return Column(
children: [
@@ -642,3 +649,386 @@ class _ToolSection extends StatelessWidget {
);
}
}
class _CategoryManagementTool extends StatefulWidget {
const _CategoryManagementTool();
@override
State<_CategoryManagementTool> createState() =>
_CategoryManagementToolState();
}
class _CategoryManagementToolState extends State<_CategoryManagementTool> {
List<MediaCategoryRule> _rules = [];
@override
void initState() {
super.initState();
final raw = StorageManager.get<dynamic>(StorageKeys.mediaCategoryRules);
_rules = raw is List
? raw
.whereType<Map>()
.map(
(value) => MediaCategoryRule.fromJson(
Map<String, dynamic>.from(value),
),
)
.toList()
: MediaCategoryRule.presets();
if (raw is! List) _save();
}
Future<void> _save() => StorageManager.set(
StorageKeys.mediaCategoryRules,
_rules.map((rule) => rule.toJson()).toList(),
);
Future<void> _restorePresets() async {
setState(() => _rules = MediaCategoryRule.presets());
await _save();
}
Future<void> _editRule([MediaCategoryRule? rule]) async {
final result = await showShadDialog<MediaCategoryRule>(
context: context,
builder: (context) => _CategoryRuleDialog(initialRule: rule),
);
if (result == null || !mounted) return;
setState(() {
final index = _rules.indexWhere((item) => item.id == result.id);
if (index == -1) {
_rules.add(result);
} else {
_rules[index] = result;
}
});
await _save();
}
Future<void> _deleteRule(MediaCategoryRule rule) async {
setState(() => _rules.removeWhere((item) => item.id == rule.id));
await _save();
}
Future<void> _move(MediaCategoryRule rule, int offset) async {
final index = _rules.indexWhere((item) => item.id == rule.id);
final target = index + offset;
if (index < 0 || target < 0 || target >= _rules.length) return;
setState(() {
final moved = _rules.removeAt(index);
_rules.insert(target, moved);
});
await _save();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.all(18),
children: [
_ToolSection(
title: '影视分类规则',
description: '按 TMDB 原始语言匹配分类;默认分类会接收未匹配到其它规则的资源。',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.outline(
onPressed: _restorePresets,
leading: const Icon(Icons.restart_alt_rounded, size: 16),
child: const Text('恢复预设'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: () => _editRule(),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('新增分类'),
),
],
),
child: const SizedBox.shrink(),
),
const SizedBox(height: 16),
_ruleGroup(
title: '电影分类',
icon: Icons.movie_rounded,
kind: TMDBMediaKind.movie,
color: cs.primary,
),
const SizedBox(height: 16),
_ruleGroup(
title: '剧集分类',
icon: Icons.tv_rounded,
kind: TMDBMediaKind.tv,
color: cs.foreground,
),
],
);
}
Widget _ruleGroup({
required String title,
required IconData icon,
required TMDBMediaKind kind,
required Color color,
}) {
final rules = _rules.where((rule) => rule.mediaKind == kind).toList();
return _ToolSection(
title: title,
description: rules.isEmpty ? '尚未配置分类规则。' : '${rules.length} 条分类规则',
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: rules.isEmpty
? const SizedBox.shrink()
: Container(
decoration: BoxDecoration(
border: Border.all(
color: ShadTheme.of(context).colorScheme.border,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
for (final rule in rules)
_CategoryRuleRow(
rule: rule,
icon: icon,
color: color,
canMoveUp: _rules.indexOf(rule) > 0,
canMoveDown: _rules.indexOf(rule) < _rules.length - 1,
onMoveUp: () => _move(rule, -1),
onMoveDown: () => _move(rule, 1),
onEdit: () => _editRule(rule),
onDelete: () => _deleteRule(rule),
),
],
),
),
),
);
}
}
class _CategoryRuleRow extends StatelessWidget {
final MediaCategoryRule rule;
final IconData icon;
final Color color;
final bool canMoveUp;
final bool canMoveDown;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _CategoryRuleRow({
required this.rule,
required this.icon,
required this.color,
required this.canMoveUp,
required this.canMoveDown,
required this.onMoveUp,
required this.onMoveDown,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: cs.border.withValues(alpha: 0.7)),
),
),
child: Row(
children: [
Icon(icon, color: color, size: 19),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rule.name,
style: TextStyle(
color: cs.foreground,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
rule.isFallback
? '未设置原始语言的默认分类'
: '语言 ${rule.languages.join(', ')}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
),
if (rule.isFallback) const ShadBadge(child: Text('默认')),
const SizedBox(width: 6),
_CategoryIconButton(
tooltip: '上移',
icon: Icons.arrow_upward_rounded,
onPressed: canMoveUp ? onMoveUp : null,
),
_CategoryIconButton(
tooltip: '下移',
icon: Icons.arrow_downward_rounded,
onPressed: canMoveDown ? onMoveDown : null,
),
_CategoryIconButton(
tooltip: '编辑',
icon: Icons.edit_outlined,
onPressed: onEdit,
),
_CategoryIconButton(
tooltip: '删除',
icon: Icons.delete_outline_rounded,
onPressed: onDelete,
destructive: true,
),
],
),
);
}
}
class _CategoryIconButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
final bool destructive;
const _CategoryIconButton({
required this.tooltip,
required this.icon,
required this.onPressed,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadTooltip(
builder: (_) => Text(tooltip),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: onPressed,
child: Icon(
icon,
size: 16,
color: destructive ? cs.destructive : cs.mutedForeground,
),
),
);
}
}
class _CategoryRuleDialog extends StatefulWidget {
final MediaCategoryRule? initialRule;
const _CategoryRuleDialog({this.initialRule});
@override
State<_CategoryRuleDialog> createState() => _CategoryRuleDialogState();
}
class _CategoryRuleDialogState extends State<_CategoryRuleDialog> {
late final TextEditingController _name;
late final TextEditingController _languages;
late TMDBMediaKind _kind;
late bool _isFallback;
@override
void initState() {
super.initState();
final rule = widget.initialRule;
_name = TextEditingController(text: rule?.name ?? '');
_languages = TextEditingController(text: rule?.languages.join(', ') ?? '');
_kind = rule?.mediaKind ?? TMDBMediaKind.movie;
_isFallback = rule?.isFallback ?? false;
}
@override
void dispose() {
_name.dispose();
_languages.dispose();
super.dispose();
}
void _save() {
final name = _name.text.trim();
if (name.isEmpty) return;
final languages = _languages.text
.split(',')
.map((value) => value.trim().toLowerCase())
.where((value) => value.isNotEmpty)
.toSet()
.toList();
Navigator.of(context).pop(
MediaCategoryRule(
id:
widget.initialRule?.id ??
DateTime.now().microsecondsSinceEpoch.toString(),
name: name,
mediaKind: _kind,
languages: languages,
isFallback: _isFallback,
),
);
}
@override
Widget build(BuildContext context) {
return ShadDialog(
title: Text(widget.initialRule == null ? '新增分类' : '编辑分类'),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ShadButton(onPressed: _save, child: const Text('保存')),
],
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ShadInput(controller: _name, placeholder: const Text('分类名称')),
const SizedBox(height: 10),
ShadSelect<TMDBMediaKind>(
initialValue: _kind,
selectedOptionBuilder: (context, value) => Text(value.title),
options: const [
ShadOption(value: TMDBMediaKind.movie, child: Text('电影')),
ShadOption(value: TMDBMediaKind.tv, child: Text('剧集')),
],
onChanged: (value) {
if (value != null) setState(() => _kind = value);
},
),
const SizedBox(height: 10),
ShadInput(
controller: _languages,
enabled: !_isFallback,
placeholder: const Text('原始语言代码,以英文逗号分隔,例如 zh, en'),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ShadCheckbox(
value: _isFallback,
label: const Text('设为默认分类'),
sublabel: const Text('在其它分类均未匹配时使用'),
onChanged: (value) => setState(() => _isFallback = value),
),
),
],
),
),
);
}
}