Files

330 lines
12 KiB
Dart
Raw Permalink 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';
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;
// ⭐ 固定 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();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToCurrentSong();
});
}
@override
void dispose() {
_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: Icon(
isCurrent ? Icons.play_arrow : Icons.music_note,
color:
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600],
size: 24,
),
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);
},
);
},
),
);
}
}