Files
QingTing-Player/lib/pages/webdav_file_list_page.dart
T
lxh2875931338 c1e720311e feat(ui): 文件列表收藏红心 + 首页收藏绑定 favorites 表
交互调整(按讨论):
- WebDAV 文件列表:整行点击 = 播放(原来的行为),右侧播放按钮改成收藏红心
  已收藏实心红,未收藏空心;点击即写/删 favorites 表
- 首页"我的收藏"改为读 favorites 表 + songs 元数据;
  从文件列表返回时自动刷新
- 收藏项右侧红心改为真正删除(原来只是内存 removeAt)

修复:
- 收藏列表项透明度绑定 _listReveal,首屏为 0 导致条目完全透明
  (收藏成功但看不到)。收藏项改为固定 opacity 1.0。

验证(真机):
  列表点红心 -> favorites 表 +1 -> 返回首页 -> 收藏区出现该条目
2026-10-24 21:38:36 +08:00

440 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/pages/webdav_file_list_page.dart
import 'package:flutter/material.dart';
import '../services/webdav_service.dart';
import '../constants/ui_constants.dart';
import '../base/base_state.dart';
import '../controllers/playback_controller.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../widgets/song_artwork.dart';
class WebDAVFileListPage extends StatefulWidget {
final String currentPath;
const WebDAVFileListPage({super.key, this.currentPath = '/'});
@override
State<WebDAVFileListPage> createState() => _WebDAVFileListPageState();
}
class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
List<WebDAVItem> _items = [];
bool _isLoading = true;
String _errorMessage = '';
String _currentPath = '/';
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
Map<String, Map<String, dynamic>> _metaByPath = {};
String? _lastSyncedSongPath;
/// 已收藏的歌曲路径
Set<String> _favoritePaths = {};
// 安全解码
String _safeDecode(String input) {
try {
return Uri.decodeComponent(input);
} catch (_) {
return input;
}
}
@override
void initState() {
super.initState();
_currentPath = widget.currentPath;
AudioService().addListener(_onPlaybackChanged);
_loadDirectory();
}
@override
void dispose() {
AudioService().removeListener(_onPlaybackChanged);
super.dispose();
}
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
void _onPlaybackChanged() {
final path = AudioService().currentSong?.id;
if (path == null || path == _lastSyncedSongPath) return;
_lastSyncedSongPath = path;
_syncMetadataFor(path);
}
Future<void> _syncMetadataFor(String path) async {
if (!_items.any((item) => item.path == path)) return;
final row = await SongDatabase().getSongByPath(path);
if (row == null || !mounted) return;
final title = (row['title'] as String?)?.trim() ?? '';
if (title.isEmpty) return;
safeSetState(() {
_metaByPath = {..._metaByPath, path: row};
});
}
/// 收藏 / 取消收藏:整行点击是播放,右侧红心专门做收藏
Future<void> _toggleFavorite(String path) async {
final wasFavorite = _favoritePaths.contains(path);
try {
await SongDatabase().toggleFavorite(path);
if (!mounted) return;
safeSetState(() {
final next = {..._favoritePaths};
if (wasFavorite) {
next.remove(path);
} else {
next.add(path);
}
_favoritePaths = next;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('收藏失败: $e')),
);
}
}
}
// ============================================================
// 加载目录
// ============================================================
Future<void> _loadDirectory() async {
safeSetState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final items =
await WebDAVService.instance.listDirectory(path: _currentPath);
// 一次性把本目录下所有文件的元数据查出来,列表直接用标签显示
final paths = items
.where((item) => !item.isDirectory)
.map((item) => item.path)
.toList();
var meta = <String, Map<String, dynamic>>{};
try {
meta = await SongDatabase().getSongsByPaths(paths);
} catch (e) {
debugPrint('⚠️ [WebDAVList] 批量读取元数据失败: $e');
}
var favorites = <String>{};
try {
final favRows = await SongDatabase().getFavorites();
favorites = favRows
.map((row) => row['song_id'] as String?)
.whereType<String>()
.toSet();
} catch (e) {
debugPrint('⚠️ [WebDAVList] 读取收藏失败: $e');
}
if (mounted) {
safeSetState(() {
_items = items;
_metaByPath = meta;
_favoritePaths = favorites;
_isLoading = false;
});
// 如果当前正在播的歌就在这个目录里,补一次同步
_lastSyncedSongPath = null;
_onPlaybackChanged();
}
} catch (e) {
if (mounted) {
safeSetState(() {
_errorMessage = '加载失败: $e';
_isLoading = false;
});
}
}
}
// ============================================================
// 进入子目录
// ============================================================
void _enterDirectory(WebDAVItem dir) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WebDAVFileListPage(currentPath: dir.path),
),
);
}
// ============================================================
// ⭐ 播放单首(使用 PlaybackController)
// ============================================================
void _playSong(WebDAVItem file) async {
try {
await PlaybackController().playFromWebDAV(
directoryPath: _currentPath,
clickedSongPath: file.path,
allItems: _items,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('正在播放: ${_safeDecode(file.name)}'),
backgroundColor: const Color(0xFF4CAF50),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('播放失败: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// ============================================================
// ⭐ 全部播放(使用 PlaybackController)
// ============================================================
void _playAll() async {
try {
await PlaybackController().playAllFromWebDAV(
directoryPath: _currentPath,
allItems: _items,
);
if (mounted) {
final musicCount = _items.where((item) => !item.isDirectory).length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('开始播放全部 ($musicCount 首)'),
backgroundColor: const Color(0xFF4CAF50),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('播放失败: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// ============================================================
// 显示路径
// ============================================================
String _getDisplayPath() {
if (_currentPath == '/' || _currentPath.isEmpty) return '根目录';
final decoded = _safeDecode(_currentPath);
final parts = decoded.split('/').where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return '根目录';
if (parts.length <= 2) return parts.join(' / ');
return '... / ${parts.sublist(parts.length - 2).join(' / ')}';
}
// ============================================================
// Build
// ============================================================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
title: Text(
_getDisplayPath(),
style: const TextStyle(fontSize: 16),
),
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.playlist_play),
onPressed: _playAll,
tooltip: '全部播放',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadDirectory,
tooltip: '刷新',
),
],
),
body: _isLoading
? const Center(
child: CircularProgressIndicator(
color: Color(0xFFB8D4D0),
),
)
: _errorMessage.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(color: Colors.grey[400]),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadDirectory,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB8D4D0),
foregroundColor: Colors.black87,
),
child: const Text('重试'),
),
],
),
)
: _items.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open,
size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'此目录为空',
style: TextStyle(color: Colors.grey[400]),
),
const SizedBox(height: 8),
Text(
'支持格式: MP3, FLAC, M4A, APE, WAV, OPUS',
style: TextStyle(
color: Colors.grey[600], fontSize: 12),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: UIConstants.miniPlayerBottomSpace,
),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return _buildListItem(item);
},
),
);
}
// ============================================================
// 构建列表项
// ============================================================
Widget _buildListItem(WebDAVItem item) {
final displayName = _safeDecode(item.name);
if (item.isDirectory) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: const Icon(
Icons.folder_outlined,
color: Color(0xFFB8D4D0),
size: 32,
),
title: Text(
displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'文件夹',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
trailing: const Icon(
Icons.chevron_right,
color: Colors.grey,
),
onTap: () => _enterDirectory(item),
);
} else {
final sizeStr = item.size != null
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
: '';
// 有标签就用标签,没有才退回文件名(例如新歌尚未抓取元数据)
final meta = _metaByPath[item.path];
final metaTitle = (meta?['title'] as String?)?.trim() ?? '';
final metaArtist = (meta?['artist'] as String?)?.trim() ?? '';
final hasMeta = metaTitle.isNotEmpty;
final titleText = hasMeta ? metaTitle : displayName;
final subtitleText = [
if (hasMeta && metaArtist.isNotEmpty && metaArtist != '未知艺术家')
metaArtist,
if (sizeStr.isNotEmpty) sizeStr,
].join(' · ');
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: SongArtwork(
filePath: meta?['artwork_path'] as String?,
size: 44,
fallbackIcon: Icons.audiotrack,
),
title: Text(
titleText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
subtitleText,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
trailing: IconButton(
tooltip: '收藏',
icon: Icon(
_favoritePaths.contains(item.path)
? Icons.favorite
: Icons.favorite_border,
color: _favoritePaths.contains(item.path)
? const Color(0xFFE05C5C)
: Colors.grey,
),
onPressed: () => _toggleFavorite(item.path),
),
onTap: () => _playSong(item),
);
}
}
}