feat: add media categories and folder selection
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user