diff --git a/lib/pages/playlist_page.dart b/lib/pages/playlist_page.dart index 4f62323..a9699e8 100644 --- a/lib/pages/playlist_page.dart +++ b/lib/pages/playlist_page.dart @@ -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 { int _scrollGeneration = 0; bool _hasScrolledToCurrent = false; + /// path -> 封面文件路径(来自 songs 表) + Map _artworkByPath = {}; + // ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整) // 实际约 72-76px,取 74px 作为稳定值 static const double _itemExtent = 74.0; @@ -28,13 +33,46 @@ class _PlaylistPageState extends State { @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 _loadArtworkPaths() async { + try { + final queue = AudioService().queue; + if (queue.isEmpty) return; + final rows = + await SongDatabase().getSongsByPaths(queue.map((s) => s.id).toList()); + final map = {}; + 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 { horizontal: 8, vertical: 4, ), - leading: Icon( - isCurrent ? Icons.play_arrow : Icons.music_note, - color: - isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600], - size: 24, + leading: SongArtwork( + bytes: song.artwork, + filePath: _artworkByPath[song.id], + size: 48, + fallbackIcon: + isCurrent ? Icons.play_arrow : Icons.music_note, + fallbackColor: isCurrent + ? const Color(0xFFB8D4D0) + : Colors.grey[600], ), title: Text( song.title, diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index 05f5679..3cfb5c9 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -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 { 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, diff --git a/lib/widgets/song_artwork.dart b/lib/widgets/song_artwork.dart new file mode 100644 index 0000000..d5ce113 --- /dev/null +++ b/lib/widgets/song_artwork.dart @@ -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), + ), + ); + } +}