330 lines
12 KiB
Dart
330 lines
12 KiB
Dart
// 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);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|