1198 lines
39 KiB
Dart
1198 lines
39 KiB
Dart
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 '../providers/media_library_provider.dart';
|
|
|
|
class MediaLibraryPage extends ConsumerStatefulWidget {
|
|
final bool showLibrarySidebar;
|
|
final String? searchTitle;
|
|
|
|
const MediaLibraryPage({
|
|
super.key,
|
|
this.showLibrarySidebar = true,
|
|
this.searchTitle,
|
|
});
|
|
|
|
static void showCreateDialog(BuildContext context, WidgetRef ref) {
|
|
_MediaLibraryPageState._showCreateLibraryDialog(context, ref);
|
|
}
|
|
|
|
@override
|
|
ConsumerState<MediaLibraryPage> createState() => _MediaLibraryPageState();
|
|
}
|
|
|
|
class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
|
String _tmdbApiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
|
|
bool _showApiKeyInput = false;
|
|
bool _tmdbSearching = false;
|
|
String? _tmdbError;
|
|
List<Map<String, dynamic>> _tmdbResults = [];
|
|
final _apiKeyController = TextEditingController();
|
|
final _searchController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_apiKeyController.text = _tmdbApiKey;
|
|
Future.microtask(() {
|
|
ref.read(mediaLibraryProvider.notifier).api = ref
|
|
.read(authProvider.notifier)
|
|
.api;
|
|
ref.read(mediaLibraryProvider.notifier).load();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_apiKeyController.dispose();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(mediaLibraryProvider);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(context, state),
|
|
const SizedBox(height: 12),
|
|
_buildToolbar(context, state),
|
|
const SizedBox(height: 12),
|
|
if (state.errorMessage != null || state.statusMessage != null)
|
|
_buildMessageBar(context, state),
|
|
Expanded(
|
|
child: widget.showLibrarySidebar
|
|
? Row(
|
|
children: [
|
|
_buildLibraryList(context, state),
|
|
VerticalDivider(
|
|
width: 24,
|
|
color: ShadTheme.of(context).colorScheme.border,
|
|
),
|
|
Expanded(child: _buildMainPanel(context, state)),
|
|
],
|
|
)
|
|
: _buildMainPanel(context, state),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(BuildContext context, MediaLibraryState state) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final stats = state.statistics;
|
|
return Row(
|
|
children: [
|
|
Icon(Icons.movie_filter_rounded, size: 26, color: cs.primary),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.searchTitle ?? '光鸭影视',
|
|
style: TextStyle(
|
|
fontSize: 21,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
Text(
|
|
state.selectedLibrary?.name ?? '创建媒体库后扫描云盘影视文件',
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_statPill(context, '全部', stats.total.toString()),
|
|
_statPill(context, '电影', stats.movies.toString()),
|
|
_statPill(context, '剧集', stats.series.toString()),
|
|
_statPill(context, '待匹配', stats.unmatched.toString()),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _statPill(BuildContext context, String label, String value) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Container(
|
|
margin: const EdgeInsets.only(left: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: cs.muted,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: cs.border),
|
|
),
|
|
child: Text(
|
|
'$label $value',
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: ShadInput(
|
|
controller: _searchController,
|
|
placeholder: const Text('搜索影视库或 TMDB…'),
|
|
leading: Icon(
|
|
Icons.search_rounded,
|
|
size: 16,
|
|
color: cs.mutedForeground,
|
|
),
|
|
onChanged: (value) =>
|
|
ref.read(mediaLibraryProvider.notifier).setSearchQuery(value),
|
|
onSubmitted: _searchTMDB,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ShadButton.outline(
|
|
onPressed: () => _showCreateLibraryDialog(context, ref),
|
|
leading: const Icon(Icons.add_rounded, size: 16),
|
|
child: const Text('媒体库'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ShadButton.outline(
|
|
onPressed: state.selectedLibrary == null || state.isScanning
|
|
? null
|
|
: () => ref
|
|
.read(mediaLibraryProvider.notifier)
|
|
.scanSelectedLibrary(),
|
|
leading: const Icon(Icons.refresh_rounded, size: 16),
|
|
child: const Text('扫描'),
|
|
),
|
|
if (state.isScanning) ...[
|
|
const SizedBox(width: 8),
|
|
ShadButton.destructive(
|
|
onPressed: () =>
|
|
ref.read(mediaLibraryProvider.notifier).cancelScan(),
|
|
leading: const Icon(Icons.stop_rounded, size: 16),
|
|
child: const Text('停止'),
|
|
),
|
|
],
|
|
const SizedBox(width: 8),
|
|
ShadButton.outline(
|
|
onPressed: () => setState(() => _showApiKeyInput = !_showApiKeyInput),
|
|
leading: Icon(
|
|
_tmdbApiKey.isEmpty ? Icons.key_off_rounded : Icons.key_rounded,
|
|
size: 16,
|
|
),
|
|
child: const Text('TMDB'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ShadButton(
|
|
onPressed: _tmdbApiKey.isEmpty
|
|
? null
|
|
: () => _searchTMDB(_searchController.text),
|
|
leading: const Icon(Icons.travel_explore_rounded, size: 16),
|
|
child: const Text('匹配'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBar(BuildContext context, MediaLibraryState state) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final isError = state.errorMessage != null;
|
|
return Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isError ? cs.destructive.withValues(alpha: 0.08) : cs.muted,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: isError ? cs.destructive : cs.border),
|
|
),
|
|
child: Text(
|
|
state.errorMessage ?? state.statusMessage ?? '',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isError ? cs.destructive : cs.mutedForeground,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLibraryList(BuildContext context, MediaLibraryState state) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return SizedBox(
|
|
width: 260,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_showApiKeyInput) _buildTMDBConfig(context),
|
|
Text(
|
|
'媒体库',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.mutedForeground,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: state.libraries.isEmpty
|
|
? _emptyLibraryHint(context)
|
|
: ListView.builder(
|
|
itemCount: state.libraries.length,
|
|
itemBuilder: (context, index) {
|
|
final library = state.libraries[index];
|
|
final selected = library.id == state.selectedLibrary?.id;
|
|
return _LibraryRow(
|
|
library: library,
|
|
selected: selected,
|
|
onTap: () => ref
|
|
.read(mediaLibraryProvider.notifier)
|
|
.selectLibrary(library.id),
|
|
onDelete: () => ref
|
|
.read(mediaLibraryProvider.notifier)
|
|
.deleteLibrary(library.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTMDBConfig(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: cs.border),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
ShadInput(
|
|
controller: _apiKeyController,
|
|
placeholder: const Text('TMDB API Key'),
|
|
obscureText: true,
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ShadButton(onPressed: _saveApiKey, child: const Text('保存')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _emptyLibraryHint(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.video_library_outlined,
|
|
size: 42,
|
|
color: cs.mutedForeground,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'暂无媒体库',
|
|
style: TextStyle(fontSize: 14, color: cs.mutedForeground),
|
|
),
|
|
const SizedBox(height: 10),
|
|
ShadButton.outline(
|
|
onPressed: () => _showCreateLibraryDialog(context, ref),
|
|
child: const Text('创建媒体库'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMainPanel(BuildContext context, MediaLibraryState state) {
|
|
if (state.isLoading) {
|
|
return const Center(child: ShadProgress());
|
|
}
|
|
if (state.isScanning) {
|
|
return _scanProgress(context, state);
|
|
}
|
|
if (_tmdbSearching || _tmdbResults.isNotEmpty || _tmdbError != null) {
|
|
return _tmdbResultPanel(context);
|
|
}
|
|
final items = state.visibleItems;
|
|
if (state.selectedLibrary == null) {
|
|
return _mainEmpty(context, '还没有媒体库', '从云盘根目录或当前目录创建一个媒体库');
|
|
}
|
|
if (items.isEmpty) {
|
|
return _mainEmpty(context, '没有扫描结果', '点击扫描读取该媒体库下的视频文件');
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final columns = (constraints.maxWidth / 220).floor().clamp(2, 6);
|
|
return GridView.builder(
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.8,
|
|
),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) => _MediaItemTile(item: items[index]),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _scanProgress(BuildContext context, MediaLibraryState state) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 360,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const ShadProgress(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
state.progress.phase,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: cs.foreground),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'已发现 ${state.progress.completed} 个视频文件',
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _mainEmpty(BuildContext context, String title, String subtitle) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.movie_creation_outlined,
|
|
size: 56,
|
|
color: cs.mutedForeground,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(title, style: TextStyle(fontSize: 16, color: cs.foreground)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tmdbResultPanel(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
if (_tmdbSearching) return const Center(child: ShadProgress());
|
|
if (_tmdbError != null) {
|
|
return _mainEmpty(context, 'TMDB 请求失败', _tmdbError!);
|
|
}
|
|
if (_tmdbResults.isEmpty) {
|
|
return _mainEmpty(context, '没有 TMDB 结果', '换一个片名继续搜索');
|
|
}
|
|
return ListView.separated(
|
|
itemCount: _tmdbResults.length,
|
|
separatorBuilder: (context, index) =>
|
|
Divider(color: cs.border, height: 1),
|
|
itemBuilder: (context, index) =>
|
|
_buildTMDBResultItem(context, _tmdbResults[index]),
|
|
);
|
|
}
|
|
|
|
Widget _buildTMDBResultItem(BuildContext context, Map<String, dynamic> item) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final title = item['title'] ?? item['name'] ?? '未知';
|
|
final overview = item['overview']?.toString() ?? '';
|
|
final releaseDate =
|
|
item['release_date']?.toString() ??
|
|
item['first_air_date']?.toString() ??
|
|
'';
|
|
final mediaType = item['media_type']?.toString() ?? 'movie';
|
|
final posterPath = item['poster_path'] as String?;
|
|
final year = releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: posterPath == null
|
|
? _posterPlaceholder(context, 74, 110)
|
|
: Image.network(
|
|
'https://image.tmdb.org/t/p/w200$posterPath',
|
|
width: 74,
|
|
height: 110,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
_posterPlaceholder(context, 74, 110),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
year.isEmpty ? title.toString() : '$title ($year)',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
),
|
|
ShadBadge(child: Text(mediaType == 'tv' ? '剧集' : '电影')),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
overview.isEmpty ? '暂无简介' : overview,
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
maxLines: 4,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _posterPlaceholder(BuildContext context, double width, double height) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Container(
|
|
width: width,
|
|
height: height,
|
|
color: cs.muted,
|
|
child: Icon(Icons.movie_rounded, color: cs.mutedForeground),
|
|
);
|
|
}
|
|
|
|
static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) {
|
|
final fileState = ref.read(fileProvider);
|
|
final currentPath = fileState.folderPath.isEmpty
|
|
? '云盘根目录'
|
|
: fileState.folderPath.map((file) => file.name).join(' / ');
|
|
showShadDialog(
|
|
context: context,
|
|
builder: (ctx) => _CreateMediaLibraryDialog(
|
|
initialRootID: fileState.folderPath.isEmpty
|
|
? null
|
|
: fileState.folderPath.last.id,
|
|
initialPath: currentPath,
|
|
initialName: fileState.folderPath.isEmpty
|
|
? '我的影视库'
|
|
: fileState.folderPath.last.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _saveApiKey() {
|
|
final key = _apiKeyController.text.trim();
|
|
StorageManager.set(StorageKeys.tmdbApiKey, key);
|
|
setState(() {
|
|
_tmdbApiKey = key;
|
|
_showApiKeyInput = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _searchTMDB(String query) async {
|
|
final text = query.trim();
|
|
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
|
|
|
|
setState(() {
|
|
_tmdbSearching = true;
|
|
_tmdbError = null;
|
|
_tmdbResults = [];
|
|
});
|
|
|
|
try {
|
|
final api = ref.read(authProvider.notifier).api;
|
|
final result = await api.tmdbSearch(
|
|
text,
|
|
apiKey: _tmdbApiKey,
|
|
proxyHost: StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
|
|
proxyPort: StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
|
|
);
|
|
final results =
|
|
(result['results'] as List?)
|
|
?.whereType<Map>()
|
|
.where(
|
|
(item) =>
|
|
item['media_type'] == 'movie' || item['media_type'] == 'tv',
|
|
)
|
|
.map((item) => Map<String, dynamic>.from(item))
|
|
.toList() ??
|
|
[];
|
|
setState(() {
|
|
_tmdbResults = results;
|
|
_tmdbSearching = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_tmdbError = e.toString();
|
|
_tmdbSearching = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 List<MediaLibrarySource> _sources;
|
|
final _browserPath = <CloudFile>[];
|
|
var _folders = <CloudFile>[];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sources = [
|
|
MediaLibrarySource(
|
|
id: 'initial-source',
|
|
rootID: widget.initialRootID,
|
|
path: 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() {
|
|
final rootID = _browserFolderID;
|
|
final rootPath = _browserLocation;
|
|
setState(() {
|
|
if (rootID == null) {
|
|
_sources = [
|
|
MediaLibrarySource(id: 'root-source', rootID: null, path: rootPath),
|
|
];
|
|
} else if (!_sources.any((source) => source.rootID == rootID)) {
|
|
_sources = [
|
|
..._sources.where((source) => source.rootID != null),
|
|
MediaLibrarySource(
|
|
id: 'source-${DateTime.now().microsecondsSinceEpoch}',
|
|
rootID: rootID,
|
|
path: rootPath,
|
|
),
|
|
];
|
|
}
|
|
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: _sources.isEmpty ? null : _sources.first.rootID,
|
|
rootPath: _sources.isEmpty ? '未配置目录' : _sources.first.path,
|
|
sources: _sources,
|
|
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: _sources.isEmpty ? null : () => _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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.folder_rounded, color: cs.primary, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'媒体来源 (${_sources.length})',
|
|
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
|
),
|
|
const Spacer(),
|
|
ShadButton.outline(
|
|
size: ShadButtonSize.sm,
|
|
onPressed: _startBrowsing,
|
|
leading: const Icon(Icons.add_rounded, size: 15),
|
|
child: const Text('添加目录'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_sources.isEmpty)
|
|
Text(
|
|
'请至少添加一个云盘目录',
|
|
style: TextStyle(fontSize: 12, color: cs.destructive),
|
|
)
|
|
else
|
|
for (final source in _sources)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 5),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.folder_outlined,
|
|
size: 16,
|
|
color: cs.mutedForeground,
|
|
),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: Text(
|
|
source.path,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
),
|
|
ShadButton.ghost(
|
|
size: ShadButtonSize.sm,
|
|
onPressed: () => setState(
|
|
() => _sources.removeWhere(
|
|
(candidate) => candidate.id == source.id,
|
|
),
|
|
),
|
|
child: Icon(
|
|
Icons.remove_circle_outline_rounded,
|
|
size: 16,
|
|
color: cs.destructive,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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 MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
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;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onDelete;
|
|
|
|
const _LibraryRow({
|
|
required this.library,
|
|
required this.selected,
|
|
required this.onTap,
|
|
required this.onDelete,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? cs.primary.withValues(alpha: 0.08)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: selected ? cs.primary : cs.border),
|
|
),
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 8, 6, 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.video_library_rounded,
|
|
size: 20,
|
|
color: selected ? cs.primary : cs.mutedForeground,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
library.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: selected
|
|
? FontWeight.w700
|
|
: FontWeight.w500,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${library.kind.title} · ${library.rootPath}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: cs.mutedForeground,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
ShadTooltip(
|
|
builder: (_) => const Text('删除媒体库'),
|
|
child: ShadButton.destructive(
|
|
size: ShadButtonSize.sm,
|
|
onPressed: onDelete,
|
|
child: const Icon(Icons.delete_outline_rounded, size: 15),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MediaItemTile extends StatelessWidget {
|
|
final MediaLibraryItem item;
|
|
|
|
const _MediaItemTile({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final posterURL = item.posterPath == null || item.posterPath!.isEmpty
|
|
? null
|
|
: 'https://image.tmdb.org/t/p/w342${item.posterPath}';
|
|
final isSeries = item.mediaKind == TMDBMediaKind.tv;
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: cs.card,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: cs.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 54,
|
|
height: 76,
|
|
decoration: BoxDecoration(
|
|
color: cs.muted,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: posterURL == null
|
|
? Icon(
|
|
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
|
|
size: 24,
|
|
color: cs.mutedForeground,
|
|
)
|
|
: Image.network(
|
|
posterURL,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => Icon(
|
|
isSeries ? Icons.tv_rounded : Icons.movie_rounded,
|
|
size: 24,
|
|
color: cs.mutedForeground,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
item.year.isEmpty
|
|
? item.title
|
|
: '${item.title} (${item.year})',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
),
|
|
if (item.isMatched)
|
|
ShadBadge(child: Text(isSeries ? '剧集' : '电影')),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.isMatched
|
|
? (item.overview.isEmpty ? 'TMDB 已识别' : item.overview)
|
|
: '未识别,可在 TMDB 工具中重新匹配',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.file.cloudPath,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 10, color: cs.mutedForeground),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|