// 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 createState() => _PlaylistPageState(); } class _PlaylistPageState extends State { final ScrollController _scrollController = ScrollController(); final GlobalKey _currentTagKey = GlobalKey(); // ⭐ 定位事务 ID(防止旧任务污染) int _scrollGeneration = 0; // ⭐ 是否已完成定位 bool _hasScrolledToCurrent = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToCurrentSong(); }); } @override void dispose() { _scrollController.dispose(); super.dispose(); } /// ⭐ 核心:一次计算 + 一次动画 Future _scrollToCurrentSong() async { if (_hasScrolledToCurrent) return; final service = context.read(); final queue = service.queue; final currentIndex = service.currentIndex; if (queue.isEmpty || currentIndex < 0 || currentIndex >= queue.length) { return; } // 生成新的事务 ID,使旧任务失效 final generation = ++_scrollGeneration; // 等待页面稳定 await Future.delayed(const Duration(milliseconds: 300)); if (!mounted || generation != _scrollGeneration) return; // 获取目标 item 的当前布局状态 var tagContext = _currentTagKey.currentContext; // ⭐ 如果 Tag 还没被构建,用不可见的 jumpTo 触发布局 if (tagContext == null) { // 用 maxExtent 计算平均高度,做一次瞬时定位 final maxExtent = _scrollController.position.maxScrollExtent; final avgHeight = maxExtent / queue.length; final viewportHeight = _scrollController.position.viewportDimension; final roughOffset = (currentIndex * avgHeight) - (viewportHeight / 2) + (avgHeight / 2); final clamped = roughOffset.clamp(0.0, maxExtent); // ⭐ 瞬时跳转(用户不可见) _scrollController.jumpTo(clamped); // 等待一帧,让目标 item 被构建 await WidgetsBinding.instance.endOfFrame; if (!mounted || generation != _scrollGeneration) return; tagContext = _currentTagKey.currentContext; // 如果还是 null,说明估算偏差太大,用迭代方式推进 if (tagContext == null) { // 最多尝试 2 次,每次推进半屏 for (int i = 0; i < 2; i++) { final viewportHeight2 = _scrollController.position.viewportDimension; final direction = (currentIndex > queue.length / 2) ? -1 : 1; final step = viewportHeight2 * 0.7 * 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; // ⭐ 获取 Tag 的真实位置 final renderBox = tagContext!.findRenderObject() as RenderBox?; if (renderBox == null) { _hasScrolledToCurrent = true; 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() < 5) { _hasScrolledToCurrent = true; return; } // ⭐ 根据距离动态计算时长 final distance = (targetOffset - currentOffset).abs(); final durationMs = (250 + distance * 0.3).clamp(250, 700).round(); final duration = Duration(milliseconds: durationMs); debugPrint( '🎯 [定位] 距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms'); // ⭐ 唯一的一次可见动画 await _scrollController.animateTo( targetOffset, duration: duration, curve: Curves.easeOutCubic, ); if (mounted && generation == _scrollGeneration) { _hasScrolledToCurrent = true; } } @override Widget build(BuildContext context) { final service = context.watch(); 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); }, ); }, ), ); } }