feat(ui): 歌曲列表改用封面图,替代统一的小图标
- 新增 SongArtwork 组件:内存字节 > 本地封面文件 > 图标兜底,圆角裁剪 - WebDAV 文件列表:前导图标换成封面(来自 songs.artwork_path) - 播放列表页:批量查封面路径(getSongsByPaths),正在播放的歌优先用内存里的封面字节; 切歌后重新拉一次,保证新补全的封面能显示 - 没有封面的歌仍回退为音符/播放图标,不影响列表结构
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/audio_service.dart';
|
||||
import '../database/song_database.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
|
||||
class PlaylistPage extends StatefulWidget {
|
||||
const PlaylistPage({super.key});
|
||||
@@ -17,6 +19,9 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
||||
int _scrollGeneration = 0;
|
||||
bool _hasScrolledToCurrent = false;
|
||||
|
||||
/// path -> 封面文件路径(来自 songs 表)
|
||||
Map<String, String> _artworkByPath = {};
|
||||
|
||||
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
|
||||
// 实际约 72-76px,取 74px 作为稳定值
|
||||
static const double _itemExtent = 74.0;
|
||||
@@ -28,13 +33,46 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadArtworkPaths();
|
||||
AudioService().addListener(_onAudioChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToCurrentSong();
|
||||
});
|
||||
}
|
||||
|
||||
String? _lastArtworkSongId;
|
||||
|
||||
/// 切歌后可能有新歌补全了封面,重新拉一次路径表
|
||||
void _onAudioChanged() {
|
||||
final song = AudioService().currentSong;
|
||||
if (song == null || song.id == _lastArtworkSongId) return;
|
||||
_lastArtworkSongId = song.id;
|
||||
_loadArtworkPaths();
|
||||
}
|
||||
|
||||
/// 批量取封面文件路径,供列表显示
|
||||
Future<void> _loadArtworkPaths() async {
|
||||
try {
|
||||
final queue = AudioService().queue;
|
||||
if (queue.isEmpty) return;
|
||||
final rows =
|
||||
await SongDatabase().getSongsByPaths(queue.map((s) => s.id).toList());
|
||||
final map = <String, String>{};
|
||||
rows.forEach((path, row) {
|
||||
final artwork = row['artwork_path'] as String?;
|
||||
if (artwork != null && artwork.isNotEmpty) map[path] = artwork;
|
||||
});
|
||||
if (mounted && map.isNotEmpty) {
|
||||
setState(() => _artworkByPath = map);
|
||||
}
|
||||
} catch (_) {
|
||||
// 封面只是装饰,失败不影响列表
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
AudioService().removeListener(_onAudioChanged);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -271,11 +309,15 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Icon(
|
||||
leading: SongArtwork(
|
||||
bytes: song.artwork,
|
||||
filePath: _artworkByPath[song.id],
|
||||
size: 48,
|
||||
fallbackIcon:
|
||||
isCurrent ? Icons.play_arrow : Icons.music_note,
|
||||
color:
|
||||
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600],
|
||||
size: 24,
|
||||
fallbackColor: isCurrent
|
||||
? const Color(0xFFB8D4D0)
|
||||
: Colors.grey[600],
|
||||
),
|
||||
title: Text(
|
||||
song.title,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -358,10 +359,10 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
leading: const Icon(
|
||||
Icons.audiotrack,
|
||||
color: Color(0xFFB8D4D0),
|
||||
size: 28,
|
||||
leading: SongArtwork(
|
||||
filePath: meta?['artwork_path'] as String?,
|
||||
size: 44,
|
||||
fallbackIcon: Icons.audiotrack,
|
||||
),
|
||||
title: Text(
|
||||
titleText,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// lib/widgets/song_artwork.dart
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 歌曲封面:优先进内存字节,其次本地封面文件,最后回退图标。
|
||||
///
|
||||
/// 列表页一次会构建几十行,这里用 `existsSync` 判断一次即可,
|
||||
/// 图片本身由 Flutter 按需解码。
|
||||
class SongArtwork extends StatelessWidget {
|
||||
final Uint8List? bytes;
|
||||
final String? filePath;
|
||||
final double size;
|
||||
final IconData fallbackIcon;
|
||||
final Color? fallbackColor;
|
||||
|
||||
const SongArtwork({
|
||||
super.key,
|
||||
this.bytes,
|
||||
this.filePath,
|
||||
this.size = 44,
|
||||
this.fallbackIcon = Icons.music_note,
|
||||
this.fallbackColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
if (bytes != null && bytes!.isNotEmpty) {
|
||||
child = Image.memory(
|
||||
bytes!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, __, ___) => _fallback(),
|
||||
);
|
||||
} else if (filePath != null &&
|
||||
filePath!.isNotEmpty &&
|
||||
File(filePath!).existsSync()) {
|
||||
child = Image.file(
|
||||
File(filePath!),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, __, ___) => _fallback(),
|
||||
);
|
||||
} else {
|
||||
child = _fallback();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(width: size, height: size, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: const Color(0xFF232928),
|
||||
child: Icon(
|
||||
fallbackIcon,
|
||||
size: size * 0.55,
|
||||
color: fallbackColor ?? const Color(0xFFB8D4D0),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user