feat(search): WebDAV 文件夹内搜索
- 列表页右上角「全部播放」换成搜索(刷新保留) - 新页面 WebDAVSearchPage:输入关键字实时过滤当前文件夹的歌曲 - 匹配范围:文件名、元数据标题、艺术家(大小写不敏感) - 结果显示封面/标题/艺术家,点击从该首开始播放并返回列表 - 空状态分两种:未输入提示、无结果提示 真机验证:搜索 "forza" 命中 2 首,一条显示标签标题、一条显示文件名。
This commit is contained in:
@@ -10,6 +10,7 @@ import '../services/audio_service.dart';
|
||||
import '../services/metadata_events.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
import '../widgets/add_to_playlist_sheet.dart';
|
||||
import 'webdav_search_page.dart';
|
||||
|
||||
class WebDAVFileListPage extends StatefulWidget {
|
||||
final String currentPath;
|
||||
@@ -278,9 +279,17 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.playlist_play),
|
||||
onPressed: _playAll,
|
||||
tooltip: '全部播放',
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => WebDAVSearchPage(
|
||||
directoryPath: _currentPath,
|
||||
items: _items,
|
||||
),
|
||||
),
|
||||
),
|
||||
tooltip: '搜索本文件夹',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// lib/pages/webdav_search_page.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controllers/playback_controller.dart';
|
||||
import '../database/song_database.dart';
|
||||
import '../services/webdav_service.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
|
||||
/// 在当前 WebDAV 文件夹内搜索歌曲
|
||||
class WebDAVSearchPage extends StatefulWidget {
|
||||
final String directoryPath;
|
||||
final List<WebDAVItem> items;
|
||||
|
||||
const WebDAVSearchPage({
|
||||
super.key,
|
||||
required this.directoryPath,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebDAVSearchPage> createState() => _WebDAVSearchPageState();
|
||||
}
|
||||
|
||||
class _WebDAVSearchPageState extends State<WebDAVSearchPage> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
/// path -> songs 表行
|
||||
Map<String, Map<String, dynamic>> _meta = {};
|
||||
String _query = '';
|
||||
|
||||
List<WebDAVItem> get _files =>
|
||||
widget.items.where((item) => !item.isDirectory).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMeta();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadMeta() async {
|
||||
try {
|
||||
final paths = _files.map((item) => item.path).toList();
|
||||
final meta = await SongDatabase().getSongsByPaths(paths);
|
||||
if (!mounted) return;
|
||||
setState(() => _meta = meta);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [Search] 读取元数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _decode(String input) {
|
||||
try {
|
||||
return Uri.decodeComponent(input);
|
||||
} catch (_) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
List<WebDAVItem> get _results {
|
||||
final keyword = _query.trim().toLowerCase();
|
||||
if (keyword.isEmpty) return const [];
|
||||
return _files.where((item) {
|
||||
final name = _decode(item.name).toLowerCase();
|
||||
final meta = _meta[item.path];
|
||||
final title = (meta?['title'] as String? ?? '').toLowerCase();
|
||||
final artist = (meta?['artist'] as String? ?? '').toLowerCase();
|
||||
return name.contains(keyword) ||
|
||||
title.contains(keyword) ||
|
||||
artist.contains(keyword);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _play(WebDAVItem item) async {
|
||||
try {
|
||||
await PlaybackController().playFromWebDAV(
|
||||
directoryPath: widget.directoryPath,
|
||||
clickedSongPath: item.path,
|
||||
allItems: widget.items,
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('播放失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final results = _results;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E1211),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.white,
|
||||
titleSpacing: 0,
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
cursorColor: const Color(0xFFB8D4D0),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索本文件夹的歌曲',
|
||||
hintStyle: TextStyle(color: Colors.white38, fontSize: 15),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
actions: [
|
||||
if (_query.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
tooltip: '清除',
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _query.trim().isEmpty
|
||||
? _buildHint()
|
||||
: results.isEmpty
|
||||
? _buildEmpty()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 8),
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = results[index];
|
||||
final meta = _meta[item.path];
|
||||
final metaTitle =
|
||||
(meta?['title'] as String?)?.trim() ?? '';
|
||||
final metaArtist =
|
||||
(meta?['artist'] as String?)?.trim() ?? '';
|
||||
final artwork =
|
||||
(meta?['artwork_path'] as String?)?.trim() ?? '';
|
||||
final name = _decode(item.name);
|
||||
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: SongArtwork(
|
||||
filePath: artwork.isNotEmpty ? artwork : null,
|
||||
size: 44,
|
||||
fallbackIcon: Icons.audiotrack,
|
||||
),
|
||||
title: Text(
|
||||
metaTitle.isNotEmpty ? metaTitle : name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
metaArtist.isNotEmpty
|
||||
? metaArtist
|
||||
: (item.size != null
|
||||
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
|
||||
: ''),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _play(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHint() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search, size: 48, color: Colors.grey[700]),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'输入歌名、歌手或文件名',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'只在当前文件夹内搜索',
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Text(
|
||||
'没有找到「${_query.trim()}」',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user