Files
QingTing-Player/lib/pages/playlist_page.dart
T
lxh2875931338 d07086aaa0 feat(ui): 歌曲列表改用封面图,替代统一的小图标
- 新增 SongArtwork 组件:内存字节 > 本地封面文件 > 图标兜底,圆角裁剪
- WebDAV 文件列表:前导图标换成封面(来自 songs.artwork_path)
- 播放列表页:批量查封面路径(getSongsByPaths),正在播放的歌优先用内存里的封面字节;
  切歌后重新拉一次,保证新补全的封面能显示
- 没有封面的歌仍回退为音符/播放图标,不影响列表结构
2026-10-24 20:47:12 +08:00

372 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/playlist_page.dart
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});
@override
State<PlaylistPage> createState() => _PlaylistPageState();
}
class _PlaylistPageState extends State<PlaylistPage> {
final ScrollController _scrollController = ScrollController();
final GlobalKey _currentTagKey = GlobalKey();
int _scrollGeneration = 0;
bool _hasScrolledToCurrent = false;
/// path -> 封面文件路径(来自 songs 表)
Map<String, String> _artworkByPath = {};
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
// 实际约 72-76px,取 74px 作为稳定值
static const double _itemExtent = 74.0;
// 舒适区范围
static const double _comfortZoneTop = 0.15;
static const double _comfortZoneBottom = 0.85;
@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();
}
bool _isItemInComfortZone(
RenderBox renderBox, double viewportHeight, double listTop) {
final itemGlobal = renderBox.localToGlobal(Offset.zero);
final itemTop = itemGlobal.dy - listTop;
final itemBottom = itemTop + renderBox.size.height;
final topBoundary = viewportHeight * _comfortZoneTop;
final bottomBoundary = viewportHeight * _comfortZoneBottom;
return itemTop >= topBoundary && itemBottom <= bottomBoundary;
}
Future<void> _scrollToCurrentSong() async {
if (_hasScrolledToCurrent) return;
final service = context.read<AudioService>();
final queue = service.queue;
final currentIndex = service.currentIndex;
if (queue.isEmpty || currentIndex < 0 || currentIndex >= queue.length) {
return;
}
final generation = ++_scrollGeneration;
// ⭐ 等待两帧,确保 ListView 完全稳定
await WidgetsBinding.instance.endOfFrame;
await WidgetsBinding.instance.endOfFrame;
if (!mounted || generation != _scrollGeneration) return;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第一阶段:检测当前歌曲是否已经在舒适区内
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
var tagContext = _currentTagKey.currentContext;
if (tagContext != null) {
final renderBox = tagContext.findRenderObject() as RenderBox?;
if (renderBox != null) {
final viewportHeight = _scrollController.position.viewportDimension;
final listRenderBox = context.findRenderObject() as RenderBox?;
if (listRenderBox != null) {
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
if (_isItemInComfortZone(renderBox, viewportHeight, listTop)) {
debugPrint('🎯 [定位] 当前歌曲已在舒适区内,不滚动');
_hasScrolledToCurrent = true;
return;
}
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第二阶段:粗定位(使用固定 itemExtent,不再依赖 maxExtent)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (tagContext == null) {
final viewportHeight = _scrollController.position.viewportDimension;
// ⭐ 固定高度计算,无需读取 maxExtent,更稳定更快
final roughOffset = (currentIndex * _itemExtent) - (viewportHeight * 0.3);
final maxExtent = _scrollController.position.maxScrollExtent;
final clamped = roughOffset.clamp(0.0, maxExtent);
// ⭐ 不可见瞬时跳转
_scrollController.jumpTo(clamped);
// 等待目标被构建(只需要一帧,因为高度固定,布局更快)
await WidgetsBinding.instance.endOfFrame;
if (!mounted || generation != _scrollGeneration) return;
tagContext = _currentTagKey.currentContext;
// 如果还是 null,用迭代方式推进(但固定高度下很少需要)
if (tagContext == null) {
for (int i = 0; i < 2; i++) {
final viewportHeight2 = _scrollController.position.viewportDimension;
final direction = (currentIndex > queue.length / 2) ? -1 : 1;
final step = viewportHeight2 * 0.5 * direction;
final newOffset = (_scrollController.offset + step)
.clamp(0.0, _scrollController.position.maxScrollExtent);
_scrollController.jumpTo(newOffset);
await WidgetsBinding.instance.endOfFrame;
if (!mounted || generation != _scrollGeneration) return;
tagContext = _currentTagKey.currentContext;
if (tagContext != null) break;
}
if (tagContext == null) {
_hasScrolledToCurrent = true;
return;
}
}
}
if (!mounted || generation != _scrollGeneration) return;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第三阶段:精确定位到舒适区
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
final renderBox = tagContext!.findRenderObject() as RenderBox?;
if (renderBox == null) {
_hasScrolledToCurrent = true;
return;
}
final listRenderBox = context.findRenderObject() as RenderBox?;
if (listRenderBox == null) {
_hasScrolledToCurrent = true;
return;
}
final itemGlobal = renderBox.localToGlobal(Offset.zero);
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
final viewportHeight = _scrollController.position.viewportDimension;
final itemHeight = renderBox.size.height;
final targetTop = viewportHeight * _comfortZoneTop;
final relativeOffset = itemGlobal.dy - listTop;
final currentOffset = _scrollController.offset;
final targetOffset = (currentOffset + relativeOffset - targetTop)
.clamp(0.0, _scrollController.position.maxScrollExtent);
final distance = (targetOffset - currentOffset).abs();
if (distance < 5) {
_hasScrolledToCurrent = true;
return;
}
final isFirstEntry = _scrollController.offset == 0 && currentIndex > 5;
final durationMs = isFirstEntry
? (150 + distance * 0.12).clamp(150, 350).round()
: (200 + distance * 0.15).clamp(200, 500).round();
debugPrint(
'🎯 [定位] 偏移: ${currentOffset.toStringAsFixed(0)} → ${targetOffset.toStringAsFixed(0)}, '
'距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms');
await _scrollController.animateTo(
targetOffset,
duration: Duration(milliseconds: durationMs),
curve: Curves.easeOutCubic,
);
if (mounted && generation == _scrollGeneration) {
_hasScrolledToCurrent = true;
}
}
@override
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
final queue = service.queue;
final currentIndex = service.currentIndex;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
title: const Text(
'播放列表',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
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: Icon(service.playModeIcon, color: Colors.white54),
onPressed: service.togglePlayMode,
),
IconButton(
icon: const Icon(Icons.clear_all, color: Colors.white54),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('清空播放列表',
style: TextStyle(color: Colors.white)),
content: const Text('确定要清空当前播放列表吗?',
style: TextStyle(color: Colors.grey)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消')),
TextButton(
onPressed: () {
service.clearQueue();
Navigator.pop(context);
Navigator.pop(context);
},
child:
const Text('清空', style: TextStyle(color: Colors.red)),
),
],
),
);
},
),
],
),
body: queue.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text('播放列表为空', style: TextStyle(color: Colors.grey)),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// ⭐ 关键优化:固定 item 高度
itemExtent: _itemExtent,
itemCount: queue.length,
itemBuilder: (context, index) {
final song = queue[index];
final isCurrent = index == currentIndex;
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
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,
style: TextStyle(
fontSize: 16,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
color: isCurrent ? Colors.white : Colors.grey[300],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.artist,
style: TextStyle(
fontSize: 13,
color: isCurrent ? Colors.grey[400] : Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: isCurrent
? Container(
key: _currentTagKey,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color:
const Color(0xFFB8D4D0).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'正在播放',
style: TextStyle(
fontSize: 10,
color: Color(0xFFB8D4D0),
),
),
)
: null,
onTap: () {
service.setQueue(queue, startIndex: index);
Navigator.pop(context);
},
);
},
),
);
}
}