feat(search): WebDAV 文件夹内搜索

- 列表页右上角「全部播放」换成搜索(刷新保留)
- 新页面 WebDAVSearchPage:输入关键字实时过滤当前文件夹的歌曲
- 匹配范围:文件名、元数据标题、艺术家(大小写不敏感)
- 结果显示封面/标题/艺术家,点击从该首开始播放并返回列表
- 空状态分两种:未输入提示、无结果提示

真机验证:搜索 "forza" 命中 2 首,一条显示标签标题、一条显示文件名。
This commit is contained in:
2026-11-01 14:55:58 +08:00
parent 83ab47d8fc
commit 7d701c5dfc
2 changed files with 227 additions and 3 deletions
+12 -3
View File
@@ -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),
+215
View File
@@ -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),
),
);
}
}