diff --git a/lib/main.dart b/lib/main.dart index df70fff..5f8e52e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,17 +19,11 @@ import 'widgets/global_mini_player.dart'; final GlobalKey navigatorKey = GlobalKey(); final stopwatch = Stopwatch(); -// ⭐ 防止 Hot Restart / 热重载时重复初始化 bool _appInitialized = false; - -// ⭐ 全局缓存 Handler AudioPlayerHandler? _audioHandler; - -// ⭐ 全局缓存 LifecycleManager final AppLifecycleManager lifecycleManager = AppLifecycleManager(); void main() async { - // ⭐ P0:防止重复初始化 if (_appInitialized) { debugPrint('⏱️ main 已初始化,跳过重复执行'); return; @@ -42,7 +36,6 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); debugPrint('⏱️ T0.5 ensureInitialized: ${stopwatch.elapsedMilliseconds}ms'); - // ⭐ MediaKit 在 runApp 前初始化(官方要求) try { MediaKit.ensureInitialized(); debugPrint('⏱️ T0.6 MediaKit 初始化完成'); @@ -50,10 +43,8 @@ void main() async { debugPrint('❌ MediaKit 初始化失败: $e'); } - // ⭐ 创建 Handler _audioHandler = AudioPlayerHandler(); - // ⭐ 注册切歌回调 AudioService().setOnSongChanged((song) { debugPrint( '📢 [main] song.artwork is ${song.artwork != null ? 'not null' : 'null'}'); @@ -81,6 +72,7 @@ void main() async { debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms'); + // ⭐ 首帧后恢复播放状态 WidgetsBinding.instance.addPostFrameCallback((_) { AudioService().restorePlaybackState(); }); @@ -112,13 +104,10 @@ class RouteManager extends ChangeNotifier { // ════════════════════════════════════════════════════════════ // 导航观察者(监听路由变化) // ════════════════════════════════════════════════════════════ -// lib/main.dart - class MiniPlayerNavigatorObserver extends NavigatorObserver { void _updateRoute(Route? route) { if (route == null) return; - // ⭐ 忽略 PopupRoute(菜单、对话框等) if (route is PopupRoute) { return; } @@ -134,7 +123,6 @@ class MiniPlayerNavigatorObserver extends NavigatorObserver { @override void didPop(Route route, Route? previousRoute) { - // ⭐ 返回时,使用上一个路由(previousRoute) if (previousRoute != null && previousRoute is! PopupRoute) { final name = previousRoute.settings.name ?? '/'; RouteManager().updateRoute(name); @@ -275,9 +263,11 @@ class _QTPlayerAppState extends State { return '${DateTime.now().difference(start).inMilliseconds}'; } - // ⭐ 路由生成(播放页滑入动画,速度 200ms) - // lib/main.dart - _onGenerateRoute 方法 + // ════════════════════════════════════════════════════════════ + // ⭐ 路由生成(播放页 & 播放列表页底部滑入) + // ════════════════════════════════════════════════════════════ Route _onGenerateRoute(RouteSettings settings) { + // ⭐ 播放页:底部滑入 if (settings.name == '/player') { return PageRouteBuilder( settings: settings, @@ -285,12 +275,9 @@ class _QTPlayerAppState extends State { return const PlayerPage(); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { - // ⭐ 应用贝塞尔曲线 final curvedAnimation = CurvedAnimation( parent: animation, - curve: Curves.easeOutCubic, // 平滑减速 - // curve: Curves.easeOutBack, // 轻微回弹,更灵动 - // curve: Curves.fastOutSlowIn, // Material 风格 + curve: Curves.easeOutCubic, ); const begin = Offset(0.0, 1.0); const end = Offset.zero; @@ -301,18 +288,42 @@ class _QTPlayerAppState extends State { child: child, ); }, - transitionDuration: const Duration(milliseconds: 300), + transitionDuration: const Duration(milliseconds: 250), ); } + // ⭐ 播放列表页:底部滑入(与播放页一致) + if (settings.name == '/playlist') { + return PageRouteBuilder( + settings: settings, + pageBuilder: (context, animation, secondaryAnimation) { + return const PlaylistPage(); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + const begin = Offset(0.0, 1.0); + const end = Offset.zero; + final tween = Tween(begin: begin, end: end); + final offsetAnimation = tween.animate(curvedAnimation); + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 250), + ); + } + + // 其他页面使用默认 return MaterialPageRoute( settings: settings, builder: (context) { switch (settings.name) { case '/': return const HomePage(); - case '/playlist': - return const PlaylistPage(); default: return const HomePage(); } @@ -359,7 +370,6 @@ class _QTPlayerAppState extends State { switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, transitionBuilder: (child, animation) { - // ⭐ 淡入淡出 + 轻微上滑 return FadeTransition( opacity: animation, child: SlideTransition( diff --git a/lib/pages/playlist_page.dart b/lib/pages/playlist_page.dart index 33943d2..4f62323 100644 --- a/lib/pages/playlist_page.dart +++ b/lib/pages/playlist_page.dart @@ -14,12 +14,17 @@ class _PlaylistPageState extends State { final ScrollController _scrollController = ScrollController(); final GlobalKey _currentTagKey = GlobalKey(); - // ⭐ 定位事务 ID(防止旧任务污染) 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(); @@ -34,7 +39,18 @@ class _PlaylistPageState extends State { 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 _scrollToCurrentSong() async { if (_hasScrolledToCurrent) return; @@ -46,42 +62,59 @@ class _PlaylistPageState extends State { return; } - // 生成新的事务 ID,使旧任务失效 final generation = ++_scrollGeneration; - // 等待页面稳定 - await Future.delayed(const Duration(milliseconds: 300)); + // ⭐ 等待两帧,确保 ListView 完全稳定 + await WidgetsBinding.instance.endOfFrame; + await WidgetsBinding.instance.endOfFrame; if (!mounted || generation != _scrollGeneration) return; - // 获取目标 item 的当前布局状态 + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // 第一阶段:检测当前歌曲是否已经在舒适区内 + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ var tagContext = _currentTagKey.currentContext; - // ⭐ 如果 Tag 还没被构建,用不可见的 jumpTo 触发布局 + 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) { - // 用 maxExtent 计算平均高度,做一次瞬时定位 - final maxExtent = _scrollController.position.maxScrollExtent; - final avgHeight = maxExtent / queue.length; final viewportHeight = _scrollController.position.viewportDimension; - final roughOffset = - (currentIndex * avgHeight) - (viewportHeight / 2) + (avgHeight / 2); + // ⭐ 固定高度计算,无需读取 maxExtent,更稳定更快 + final roughOffset = (currentIndex * _itemExtent) - (viewportHeight * 0.3); + final maxExtent = _scrollController.position.maxScrollExtent; final clamped = roughOffset.clamp(0.0, maxExtent); - // ⭐ 瞬时跳转(用户不可见) + // ⭐ 不可见瞬时跳转 _scrollController.jumpTo(clamped); - // 等待一帧,让目标 item 被构建 + // 等待目标被构建(只需要一帧,因为高度固定,布局更快) await WidgetsBinding.instance.endOfFrame; if (!mounted || generation != _scrollGeneration) return; tagContext = _currentTagKey.currentContext; - // 如果还是 null,说明估算偏差太大,用迭代方式推进 + // 如果还是 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 step = viewportHeight2 * 0.5 * direction; final newOffset = (_scrollController.offset + step) .clamp(0.0, _scrollController.position.maxScrollExtent); _scrollController.jumpTo(newOffset); @@ -99,43 +132,51 @@ class _PlaylistPageState extends State { 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) { + 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(); - final durationMs = (250 + distance * 0.3).clamp(250, 700).round(); - final duration = Duration(milliseconds: durationMs); + + 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( - '🎯 [定位] 距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms'); + '🎯 [定位] 偏移: ${currentOffset.toStringAsFixed(0)} → ${targetOffset.toStringAsFixed(0)}, ' + '距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms'); - // ⭐ 唯一的一次可见动画 await _scrollController.animateTo( targetOffset, - duration: duration, + duration: Duration(milliseconds: durationMs), curve: Curves.easeOutCubic, ); @@ -218,6 +259,8 @@ class _PlaylistPageState extends State { : 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];