Files
QingTing-Player/lib/pages/playlist_page.dart
T

313 lines
10 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();
bool _hasScrolledToCurrent = false;
int _retryCount = 0;
static const int _maxRetries = 3;
// ⭐ 统一滚动时长 1.5 秒
static const Duration _scrollDuration = Duration(milliseconds: 1500);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToCurrentSong();
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
double _calculateAverageItemHeight(int itemCount) {
final maxExtent = _scrollController.position.maxScrollExtent;
if (maxExtent <= 0 || itemCount <= 0) return 72.0;
return maxExtent / itemCount;
}
double _calculateTargetOffset(
int currentIndex, int totalCount, double avgHeight) {
final viewportHeight = _scrollController.position.viewportDimension;
return (currentIndex * avgHeight) - (viewportHeight / 2) + (avgHeight / 2);
}
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;
}
await Future.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final totalCount = queue.length;
final avgHeight = _calculateAverageItemHeight(totalCount);
final maxExtent = _scrollController.position.maxScrollExtent;
var tagContext = _currentTagKey.currentContext;
if (tagContext != null) {
await _calibrateWithTag(tagContext);
_hasScrolledToCurrent = true;
return;
}
// ⭐ 粗定位:1.5 秒平滑滚动
final targetOffset =
_calculateTargetOffset(currentIndex, totalCount, avgHeight);
final clampedOffset = targetOffset.clamp(0.0, maxExtent);
await _scrollController.animateTo(
clampedOffset,
duration: _scrollDuration,
curve: Curves.easeOutCubic,
);
if (!mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
tagContext = _currentTagKey.currentContext;
if (tagContext != null) {
await _calibrateWithTag(tagContext);
_hasScrolledToCurrent = true;
return;
}
// ⭐ 迭代修正:每次 1.5 秒平滑滚动
_retryCount = 0;
var currentOffset = _scrollController.offset;
var lastOffset = currentOffset;
while (_retryCount < _maxRetries) {
_retryCount++;
final viewportHeight = _scrollController.position.viewportDimension;
final step = viewportHeight * 0.5;
final direction = (currentIndex > totalCount / 2) ? -1 : 1;
final newOffset =
(currentOffset + direction * step).clamp(0.0, maxExtent);
if ((newOffset - currentOffset).abs() < 50) {
final bigStep = viewportHeight * 0.8 * direction;
final forcedOffset = (currentOffset + bigStep).clamp(0.0, maxExtent);
await _scrollController.animateTo(
forcedOffset,
duration: _scrollDuration,
curve: Curves.easeOutCubic,
);
} else {
await _scrollController.animateTo(
newOffset,
duration: _scrollDuration,
curve: Curves.easeOutCubic,
);
}
if (!mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
tagContext = _currentTagKey.currentContext;
if (tagContext != null) {
await _calibrateWithTag(tagContext);
_hasScrolledToCurrent = true;
return;
}
currentOffset = _scrollController.offset;
if ((currentOffset - lastOffset).abs() < 10) break;
lastOffset = currentOffset;
}
_hasScrolledToCurrent = true;
}
/// 精确定位:1.5 秒平滑滚动
Future<void> _calibrateWithTag(BuildContext tagContext) async {
final renderBox = tagContext.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final tagPosition = renderBox.localToGlobal(Offset.zero);
final tagSize = renderBox.size;
final tagCenter = tagPosition.dy + tagSize.height / 2;
final screenHeight = MediaQuery.of(context).size.height;
final screenCenter = screenHeight / 2;
final delta = tagCenter - screenCenter;
final currentOffset = _scrollController.offset;
final targetOffset = (currentOffset + delta)
.clamp(0.0, _scrollController.position.maxScrollExtent);
if ((targetOffset - currentOffset).abs() < 2) return;
await _scrollController.animateTo(
targetOffset,
duration: _scrollDuration,
curve: Curves.easeOutCubic,
);
}
@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),
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);
},
);
},
),
);
}
}