From d07086aaa0d7516c8a1e8bf2133c51c63c2f3649 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 24 Oct 2026 20:47:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=AD=8C=E6=9B=B2=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=B9=E7=94=A8=E5=B0=81=E9=9D=A2=E5=9B=BE=EF=BC=8C?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E7=BB=9F=E4=B8=80=E7=9A=84=E5=B0=8F=E5=9B=BE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SongArtwork 组件:内存字节 > 本地封面文件 > 图标兜底,圆角裁剪 - WebDAV 文件列表:前导图标换成封面(来自 songs.artwork_path) - 播放列表页:批量查封面路径(getSongsByPaths),正在播放的歌优先用内存里的封面字节; 切歌后重新拉一次,保证新补全的封面能显示 - 没有封面的歌仍回退为音符/播放图标,不影响列表结构 --- lib/pages/playlist_page.dart | 52 ++++++++++++++++++-- lib/pages/webdav_file_list_page.dart | 9 ++-- lib/widgets/song_artwork.dart | 72 ++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 lib/widgets/song_artwork.dart 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), + ), + ); + } +}